From c1230d368a6054b7bc2cb4c5011013865d03be22 Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>
Date: Fri, 16 Jun 2023 08:47:32 +0200
Subject: [PATCH] Add Peer Reviewing to CW Tasks.

---
 .npmrc                                        |   1 +
 .storybook/main.js                            |  23 +
 .storybook/preview-head.html                  |  38 +
 .storybook/preview.js                         |  43 ++
 app/controllers/course/courseware.php         |  20 +-
 .../5.5.8_add_dates_to_cw_task_groups.php     |  35 +
 .../5.5.9_add_peer_review_tables.php          | 648 ++++++++++++++++++
 lib/classes/JsonApi/RouteMap.php              |  18 +
 .../JsonApi/Routes/Courseware/Authority.php   | 123 +++-
 .../Courseware/PeerReview/ProcessesCreate.php | 126 ++++
 .../Courseware/PeerReview/ProcessesDelete.php |  39 ++
 .../Courseware/PeerReview/ProcessesIndex.php  | 108 +++
 .../Courseware/PeerReview/ProcessesShow.php   |  49 ++
 .../Courseware/PeerReview/ProcessesUpdate.php | 123 ++++
 .../PeerReview/ReviewsByTaskIndex.php         |  80 +++
 .../Courseware/PeerReview/ReviewsCreate.php   | 184 +++++
 .../Courseware/PeerReview/ReviewsDelete.php   |  39 ++
 .../Courseware/PeerReview/ReviewsIndex.php    |  80 +++
 .../PeerReview/ReviewsOfProcessesIndex.php    |  77 +++
 .../Courseware/PeerReview/ReviewsUpdate.php   |  82 +++
 .../Routes/Courseware/TaskGroupsCreate.php    |  25 +-
 .../Routes/Courseware/TaskGroupsDelete.php    |  38 +
 .../Routes/Courseware/TaskGroupsShow.php      |   1 +
 .../Routes/Courseware/TaskGroupsUpdate.php    |  85 +++
 .../JsonApi/Routes/Courseware/TasksIndex.php  |   1 +
 .../JsonApi/Routes/Courseware/TasksShow.php   |   4 +
 .../JsonApi/Routes/Courseware/TasksUpdate.php |  65 +-
 lib/classes/JsonApi/SchemaMap.php             |  12 +-
 .../JsonApi/Schemas/Courseware/PeerReview.php |  89 +++
 .../Schemas/Courseware/PeerReviewProcess.php  |  77 +++
 .../JsonApi/Schemas/Courseware/Task.php       |  38 +
 .../JsonApi/Schemas/Courseware/TaskGroup.php  |  21 +
 lib/models/Courseware/PeerReview.php          | 128 ++++
 lib/models/Courseware/PeerReviewProcess.php   | 188 +++++
 lib/models/Courseware/StructuralElement.php   |   2 +-
 lib/models/Courseware/Task.php                |  96 ++-
 lib/models/Courseware/TaskGroup.php           | 104 ++-
 lib/models/Statusgruppen.php                  |  13 +
 lib/modules/CoursewareModule.class.php        |   4 +
 package.json                                  |  15 +-
 resources/assets/javascripts/lib/gettext.ts   | 114 +++
 resources/stories/ActionMenu.stories.js       |  34 +
 resources/stories/ContentBox.stories.js       |  30 +
 .../Tasks/PeerReviewEditorForm.stories.js     |  25 +
 .../Tasks/PeerReviewEditorTable.stories.js    |  25 +
 .../PeerReviewProcessCreateDialog.stories.js  |  99 +++
 resources/stories/DatePicker.stories.js       |  28 +
 resources/stories/DateTime.stories.js         |  27 +
 resources/stories/Icon.stories.js             |  27 +
 .../stories/Icons-Documentations.stories.mdx  |  58 ++
 resources/stories/MessageBox.stories.js       |  39 ++
 resources/vue-gettext.d.ts                    |  17 +
 resources/vue/components/DatePicker.vue       |  78 +++
 resources/vue/components/Datetimepicker.vue   |  12 +-
 resources/vue/components/StudipActionMenu.vue |  68 +-
 resources/vue/components/StudipArticle.vue    |  63 ++
 resources/vue/components/StudipContentBox.vue |  46 ++
 resources/vue/components/StudipDate.vue       |  27 +
 resources/vue/components/StudipUserAvatar.vue |  32 +
 .../CoursewareDashboardStudents.vue           | 481 -------------
 .../layouts/CoursewareCollapsibleBox.vue      |   7 +-
 .../CoursewareStructuralElement.vue           |  60 +-
 .../structural-element/CoursewareTreeItem.vue |   7 +-
 .../courseware/tasks/AddFeedbackDialog.vue    |  48 ++
 .../tasks/CoursewareDashboardStudents.vue     |  91 +++
 .../{ => tasks}/CoursewareDashboardTasks.vue  |  10 +-
 .../CoursewareTasksDialogDistribute.vue       |  56 +-
 .../courseware/tasks/EditFeedbackDialog.vue   |  60 ++
 .../PagesTaskGroupsIndex.vue}                 |  10 +-
 .../courseware/tasks/PagesTaskGroupsShow.vue  | 238 +++++++
 .../courseware/tasks/RenewalDialog.vue        |  79 +++
 .../components/courseware/tasks/TaskGroup.vue | 169 +++++
 .../TaskGroupPeerReviewProcessListItem.vue    |  68 ++
 .../courseware/tasks/TaskGroupTaskItem.vue    | 120 ++++
 .../tasks/TaskGroupsDeleteDialog.vue          |  38 +
 .../tasks/TaskGroupsModifyDeadlineDialog.vue  | 109 +++
 .../tasks/peer-review/AssessmentDialog.vue    |  91 +++
 .../AssessmentTypeEditorDialog.vue            |  86 +++
 .../tasks/peer-review/AssessmentTypeForm.vue  |  96 +++
 .../peer-review/AssessmentTypeFreetext.vue    |  89 +++
 .../tasks/peer-review/AssessmentTypeTable.vue | 138 ++++
 .../tasks/peer-review/EditorForm.vue          | 153 +++++
 .../tasks/peer-review/EditorTable.vue         | 133 ++++
 .../tasks/peer-review/PagesProcessesIndex.vue |  34 +
 .../tasks/peer-review/PagesProcessesShow.vue  | 311 +++++++++
 .../tasks/peer-review/PairingEditor.vue       | 224 ++++++
 .../tasks/peer-review/PairingEditorDialog.vue |  89 +++
 .../peer-review/PeerReviewAssignments.vue     | 102 +++
 .../tasks/peer-review/PeerReviewList.vue      |  53 ++
 .../tasks/peer-review/PeerReviewListItem.vue  | 118 ++++
 .../peer-review/ProcessConfiguration.vue      |  40 ++
 .../tasks/peer-review/ProcessCreateDialog.vue |  65 ++
 .../tasks/peer-review/ProcessCreateForm.vue   | 258 +++++++
 .../peer-review/ProcessDurationDialog.vue     | 122 ++++
 .../tasks/peer-review/ProcessEditDialog.vue   |  65 ++
 .../tasks/peer-review/ProcessStatus.vue       |  40 ++
 .../tasks/peer-review/ProcessesList.vue       |  88 +++
 .../tasks/peer-review/ProcessesListItem.vue   | 230 +++++++
 .../tasks/peer-review/definitions.ts          |  57 ++
 .../peer-review/process-configuration.ts      | 129 ++++
 .../courseware/tasks/task-groups-helper.js    |  31 +
 .../widgets/CoursewareTasksActionWidget.vue   |  37 +-
 .../vue/components/forms/LabelRequired.vue    |  22 +
 resources/vue/courseware-index-app.js         |   4 +
 resources/vue/courseware-tasks-app.js         |  65 +-
 .../vue/mixins/courseware/task-helper.js      |   6 +-
 .../courseware/courseware-tasks.module.js     | 135 +++-
 .../vue/store/courseware/courseware.module.js |   2 +-
 .../jsonapi/PeerReviewProcessesIndexTest.php  |  60 ++
 tsconfig.json                                 |   8 +-
 webpack.common.js                             |   1 +
 webpack.dev.js                                |   8 +-
 112 files changed, 7988 insertions(+), 644 deletions(-)
 create mode 100644 .storybook/main.js
 create mode 100644 .storybook/preview-head.html
 create mode 100644 .storybook/preview.js
 create mode 100644 db/migrations/5.5.8_add_dates_to_cw_task_groups.php
 create mode 100644 db/migrations/5.5.9_add_peer_review_tables.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
 create mode 100644 lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
 create mode 100644 lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php
 create mode 100644 lib/models/Courseware/PeerReview.php
 create mode 100644 lib/models/Courseware/PeerReviewProcess.php
 create mode 100644 resources/assets/javascripts/lib/gettext.ts
 create mode 100644 resources/stories/ActionMenu.stories.js
 create mode 100644 resources/stories/ContentBox.stories.js
 create mode 100644 resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js
 create mode 100644 resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js
 create mode 100644 resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js
 create mode 100644 resources/stories/DatePicker.stories.js
 create mode 100644 resources/stories/DateTime.stories.js
 create mode 100644 resources/stories/Icon.stories.js
 create mode 100644 resources/stories/Icons-Documentations.stories.mdx
 create mode 100644 resources/stories/MessageBox.stories.js
 create mode 100644 resources/vue-gettext.d.ts
 create mode 100644 resources/vue/components/DatePicker.vue
 create mode 100644 resources/vue/components/StudipArticle.vue
 create mode 100644 resources/vue/components/StudipContentBox.vue
 create mode 100644 resources/vue/components/StudipDate.vue
 create mode 100644 resources/vue/components/StudipUserAvatar.vue
 delete mode 100644 resources/vue/components/courseware/CoursewareDashboardStudents.vue
 create mode 100644 resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
 rename resources/vue/components/courseware/{ => tasks}/CoursewareDashboardTasks.vue (97%)
 rename resources/vue/components/courseware/{ => tasks}/CoursewareTasksDialogDistribute.vue (93%)
 create mode 100644 resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
 rename resources/vue/components/courseware/{TasksApp.vue => tasks/PagesTaskGroupsIndex.vue} (70%)
 create mode 100644 resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
 create mode 100644 resources/vue/components/courseware/tasks/RenewalDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroup.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/definitions.ts
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/process-configuration.ts
 create mode 100644 resources/vue/components/courseware/tasks/task-groups-helper.js
 create mode 100644 resources/vue/components/forms/LabelRequired.vue
 create mode 100644 tests/jsonapi/PeerReviewProcessesIndexTest.php

diff --git a/.npmrc b/.npmrc
index b6f27f13595..d5831dd5188 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
 engine-strict=true
+legacy-peer-deps=true
diff --git a/.storybook/main.js b/.storybook/main.js
new file mode 100644
index 00000000000..a42b6252d7f
--- /dev/null
+++ b/.storybook/main.js
@@ -0,0 +1,23 @@
+const path = require('path');
+
+module.exports = {
+    stories: ['../resources/stories/**/*.stories.mdx', '../resources/stories/**/*.stories.@(js|jsx|ts|tsx)'],
+    addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
+    framework: '@storybook/vue',
+    core: {
+        builder: '@storybook/builder-webpack5',
+        options: {
+            lazyCompilation: true,
+            fsCache: true,
+        },
+    },
+    webpackFinal: async (config, { configType }) => {
+        config.module.rules.push({
+            test: /\.scss$/,
+            use: ['style-loader', 'css-loader', 'sass-loader'],
+            include: path.resolve(__dirname, '../'),
+        });
+
+        return config;
+    },
+};
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 00000000000..dfa65689c89
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1,38 @@
+<link
+    media="screen"
+    rel="stylesheet"
+    href="%STORYBOOK_STUDIP_ASSETS_URL%/stylesheets/studip-base.css?v=5.2.alpha-svn"
+/>
+<script>
+    var STUDIP = {
+        ASSETS_URL: '%STORYBOOK_STUDIP_ASSETS_URL%/',
+        ABSOLUTE_URI_STUDIP: '%STORYBOOK_ABSOLUTE_URI_STUDIP%/',
+
+        INSTALLED_LANGUAGES: {
+            de_DE: {
+                path: 'de',
+                picture: 'lang_de.gif',
+                name: 'Deutsch',
+                selected: false,
+            },
+            en_GB: {
+                path: 'en',
+                picture: 'lang_en.gif',
+                name: 'English',
+                selected: true,
+            },
+        },
+        STUDIP_SHORT_NAME: 'Stud.IP',
+        URLHelper: {
+            base_url: '%STORYBOOK_ABSOLUTE_URI_STUDIP%/',
+            parameters: {},
+        },
+        config: {
+            ACTIONMENU_THRESHOLD: 1,
+            ENTRIES_PER_PAGE: 20,
+            OPENGRAPH_ENABLE: false,
+        },
+        jsupdate_enable: false,
+        wysiwyg_enabled: true,
+    };
+</script>
diff --git a/.storybook/preview.js b/.storybook/preview.js
new file mode 100644
index 00000000000..3a6003bfe07
--- /dev/null
+++ b/.storybook/preview.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import GetTextPlugin from 'vue-gettext';
+import jquery from 'jquery';
+import lodash from 'lodash';
+import eventBus from '../resources/assets/javascripts/lib/event-bus.ts';
+import { ready } from '../resources/assets/javascripts/lib/ready.js';
+import { getLocale, getVueConfig } from '../resources/assets/javascripts/lib/gettext.js';
+import { createURLHelper } from '../resources/assets/javascripts/lib/url_helper.ts';
+import StudipStore from '../resources/vue/store/StudipStore.js';
+import '../resources/assets/javascripts/jquery-bundle.js';
+
+Vue.use(GetTextPlugin, getVueConfig());
+Vue.use(Vuex);
+
+const store = new Vuex.Store({});
+store.registerModule('studip', StudipStore);
+Vue.mixin({
+    methods: {
+        getStudipConfig: store.getters['studip/getConfig'],
+    },
+});
+
+globalThis.$ = jquery;
+globalThis._ = lodash;
+
+const URLHelper = createURLHelper({ base_url: process.env.STORYBOOK_ABSOLUTE_URI_STUDIP, parameters: {} });
+globalThis.STUDIP = {
+    ...globalThis.STUDIP,
+    eventBus,
+    ready,
+    URLHelper,
+};
+
+export const parameters = {
+    actions: { argTypesRegex: '^on[A-Z].*' },
+    controls: {
+        matchers: {
+            color: /(background|color)$/i,
+            date: /Date$/,
+        },
+    },
+};
diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index 967d993b763..718f84389d6 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -70,11 +70,23 @@ class Course_CoursewareController extends CoursewareController
         }
     }
 
-    public function tasks_action(): void
+    public function tasks_action($route = null): void
     {
-        global $perm, $user;
-        $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id);
-        Navigation::activateItem('course/courseware/tasks');
+        $this->is_teacher = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            Context::getId(),
+            $GLOBALS['user']->id
+        );
+        switch ($route) {
+            case 'peer-review-processes':
+                Navigation::activateItem('course/courseware/peer-review');
+                PageLayout::setTitle(_('Courseware: Peer-Review-Prozesse'));
+                break;
+            default:
+                Navigation::activateItem('course/courseware/tasks');
+                PageLayout::setTitle(_('Courseware: Aufgaben'));
+                break;
+        }
         $this->setTasksSidebar();
     }
 
diff --git a/db/migrations/5.5.8_add_dates_to_cw_task_groups.php b/db/migrations/5.5.8_add_dates_to_cw_task_groups.php
new file mode 100644
index 00000000000..6ac165ec27b
--- /dev/null
+++ b/db/migrations/5.5.8_add_dates_to_cw_task_groups.php
@@ -0,0 +1,35 @@
+<?php
+class AddDatesToCwTaskGroups extends Migration
+{
+    public function description()
+    {
+        return 'TODO';
+    }
+
+    public function up()
+    {
+        $dbm = \DBManager::get();
+        $dbm->exec(
+            "ALTER TABLE `cw_task_groups`
+             ADD `start_date` INT NOT NULL AFTER `title`,
+             ADD `end_date` INT NOT NULL AFTER `start_date`"
+        );
+        $dbm->exec('UPDATE `cw_task_groups` SET `start_date`=`mkdate`');
+        $dbm->exec(
+            'UPDATE `cw_task_groups` AS tg SET tg.`end_date` = ( SELECT MAX(t.`submission_date`) FROM `cw_tasks` t WHERE t.`task_group_id` = tg.`id` )'
+        );
+        $dbm->exec('ALTER TABLE `cw_tasks` DROP `submission_date`');
+    }
+
+    public function down()
+    {
+        $dbm = \DBManager::get();
+        $dbm->exec("ALTER TABLE `cw_tasks` ADD `submission_date` int(11) NOT NULL AFTER `solver_type`");
+        $dbm->exec('UPDATE `cw_tasks` AS t INNER JOIN cw_task_groups tg ON t.`task_group_id` = tg.`id` SET t.`submission_date` = tg.`end_date`');
+        $dbm->exec(
+            'ALTER TABLE `cw_task_groups`
+             DROP `start_date`,
+             DROP `end_date`'
+        );
+    }
+}
diff --git a/db/migrations/5.5.9_add_peer_review_tables.php b/db/migrations/5.5.9_add_peer_review_tables.php
new file mode 100644
index 00000000000..61c13a585c0
--- /dev/null
+++ b/db/migrations/5.5.9_add_peer_review_tables.php
@@ -0,0 +1,648 @@
+<?php
+class AddPeerReviewTables extends Migration
+{
+    public function description()
+    {
+        return "TODO";
+    }
+
+    public function up()
+    {
+        $db = \DBManager::get();
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_review_processes`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `task_group_id` INT(11) NOT NULL,
+                 `owner_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `configuration` MEDIUMTEXT NOT NULL,
+                 `review_start` INT(11) NOT NULL,
+                 `review_end` INT(11) NOT NULL,
+                 `pairingdate` INT(11) NULL,
+                 `mkdate` INT(11) NOT NULL,
+                 `chdate` INT(11) NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_task_group_id(`task_group_id`),
+                 INDEX index_owner_id(`owner_id`)
+             )"
+        );
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_reviews`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `process_id` INT(11) NOT NULL,
+                 `task_id` INT(11) NOT NULL,
+                 `submitter_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_type` ENUM('autor', 'group') COLLATE latin1_bin,
+                 `assessment` TEXT,
+                 `mkdate` INT(11) NOT NULL,
+                 `chdate` INT(11) NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_process_id(`process_id`),
+                 INDEX index_task_id(`task_id`),
+                 INDEX index_submitter_id(`submitter_id`),
+                 INDEX index_reviewer_id(`reviewer_id`)
+             )"
+        );
+    }
+
+    public function down()
+    {
+        $db = \DBManager::get();
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_reviews`');
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_review_processes`');
+    }
+
+
+
+
+        /*
+-- phpMyAdmin SQL Dump
+-- version 5.2.0
+-- https://www.phpmyadmin.net/
+--
+-- Host: mysql
+-- Generation Time: Jun 16, 2023 at 08:53 AM
+-- Server version: 5.7.38
+-- PHP Version: 8.0.19
+
+SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
+START TRANSACTION;
+SET time_zone = "+00:00";
+
+--
+-- Database: `studip`
+--
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_answers`
+--
+
+CREATE TABLE `clk_answers` (
+  `answer_id` int(10) UNSIGNED NOT NULL,
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `answer_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `answer_text` text CHARACTER SET utf8mb4,
+  `chdate_answer` int(10) UNSIGNED DEFAULT NULL,
+  `notes` text CHARACTER SET utf8mb4,
+  `chdate_notes` int(10) UNSIGNED DEFAULT NULL,
+  `notes_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `feedback` text CHARACTER SET utf8mb4,
+  `feedback_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `chdate_feedback` int(10) UNSIGNED DEFAULT NULL,
+  `rating` tinyint(4) DEFAULT NULL,
+  `condition_reached` tinyint(4) DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_exercises`
+--
+
+CREATE TABLE `clk_exercises` (
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `exercise_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `visible` int(10) UNSIGNED DEFAULT NULL,
+  `deadline` int(10) UNSIGNED DEFAULT NULL,
+  `sort` smallint(6) DEFAULT NULL,
+  `review_end` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review` tinyint(4) UNSIGNED DEFAULT NULL,
+  `dependency` varchar(32) CHARACTER SET utf8mb4 DEFAULT 'none',
+  `sample_solution` text CHARACTER SET utf8mb4,
+  `task` text CHARACTER SET utf8mb4,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_extensions`
+--
+
+CREATE TABLE `clk_extensions` (
+  `extension_id` int(10) UNSIGNED NOT NULL,
+  `exercise_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `extension_date` int(10) UNSIGNED DEFAULT NULL,
+  `extension_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'request',
+  `reason` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) DEFAULT NULL,
+  `chdate` int(10) DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_file_object`
+--
+
+CREATE TABLE `clk_file_object` (
+  `file_id` int(10) UNSIGNED NOT NULL,
+  `object_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `object_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_title` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `mime_type` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `file_size` int(11) DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_group_excluded`
+--
+
+CREATE TABLE `clk_group_excluded` (
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `group_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_options`
+--
+
+CREATE TABLE `clk_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `topic_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `exercise_prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `display_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 'CloCked',
+  `groups_only` tinyint(4) DEFAULT NULL,
+  `peer_review_enabled` tinyint(4) DEFAULT NULL,
+  `speedgrader_enabled` tinyint(4) DEFAULT NULL,
+  `motivatoring_active` tinyint(4) DEFAULT NULL,
+  `rating_enabled` tinyint(4) DEFAULT '1',
+  `extensions_active` tinyint(4) DEFAULT '1',
+  `extensions_notification` char(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `feedback_salutation` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `feedback_greets` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_options`
+--
+
+CREATE TABLE `clk_peer_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `peer_anonym` tinyint(4) DEFAULT '1',
+  `peer_review_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `peer_review_period` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review_form` text COLLATE utf8mb4_unicode_ci,
+  `peer_tutorial` tinyint(4) DEFAULT '1'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_pairing`
+--
+
+CREATE TABLE `clk_peer_pairing` (
+  `pairing_id` int(10) UNSIGNED NOT NULL,
+  `relation_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `author_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `reviewer_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `relation_spec` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_peer_review`
+--
+
+CREATE TABLE `clk_peer_review` (
+  `review_id` int(10) UNSIGNED NOT NULL,
+  `answer_id` int(10) UNSIGNED NOT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `review_user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED DEFAULT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `review_end` int(10) UNSIGNED DEFAULT NULL,
+  `review_start` int(10) UNSIGNED DEFAULT NULL,
+  `peer_review` text CHARACTER SET utf8mb4,
+  `review_feedback` text COLLATE utf8mb4_unicode_ci,
+  `review_form` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `publish` tinyint(4) DEFAULT NULL,
+  `review_published` tinyint(2) DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_rating_text`
+--
+
+CREATE TABLE `clk_rating_text` (
+  `rating` int(11) NOT NULL,
+  `rating_text` tinytext COLLATE utf8mb4_unicode_ci,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_criteria`
+--
+
+CREATE TABLE `clk_sg_criteria` (
+  `criterium_id` int(10) UNSIGNED NOT NULL,
+  `evaluation_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `details` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_evaluations`
+--
+
+CREATE TABLE `clk_sg_evaluations` (
+  `evaluation_id` int(10) UNSIGNED NOT NULL,
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `evaluation_title` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `course_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `exercise_id` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `visibility` tinyint(3) DEFAULT '0',
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_ratings`
+--
+
+CREATE TABLE `clk_sg_ratings` (
+  `rating_id` int(10) UNSIGNED NOT NULL,
+  `criterium_id` int(10) UNSIGNED NOT NULL,
+  `classification` decimal(9,2) DEFAULT NULL,
+  `description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `details` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_sg_userratings`
+--
+
+CREATE TABLE `clk_sg_userratings` (
+  `userratings_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL,
+  `exercise_id` int(10) UNSIGNED DEFAULT NULL,
+  `answer_id` int(10) UNSIGNED DEFAULT NULL,
+  `criterium_id` int(10) UNSIGNED DEFAULT NULL,
+  `classification` decimal(9,2) DEFAULT NULL,
+  `details` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_tan`
+--
+
+CREATE TABLE `clk_tan` (
+  `tan_id` int(10) UNSIGNED NOT NULL,
+  `tan` int(10) UNSIGNED DEFAULT NULL,
+  `relation_id` int(10) UNSIGNED DEFAULT NULL,
+  `relation` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
+  `tan_working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_topics`
+--
+
+CREATE TABLE `clk_topics` (
+  `topic_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `topic_title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `description` text CHARACTER SET utf8mb4,
+  `mkdate` int(10) UNSIGNED NOT NULL,
+  `chdate` int(10) UNSIGNED DEFAULT NULL,
+  `visible` int(10) UNSIGNED DEFAULT NULL,
+  `sort` smallint(6) DEFAULT NULL,
+  `evaluation_id` int(10) UNSIGNED DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_topic_excluded`
+--
+
+CREATE TABLE `clk_topic_excluded` (
+  `topic_id` int(10) NOT NULL,
+  `working_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_user_options`
+--
+
+CREATE TABLE `clk_user_options` (
+  `option_id` int(10) UNSIGNED NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `course_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+  `show_progress` tinyint(4) DEFAULT NULL,
+  `deadline_notify` tinyint(4) DEFAULT NULL,
+  `show_result_overview` tinyint(4) DEFAULT NULL,
+  `result_list_type` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'items'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+-- --------------------------------------------------------
+
+--
+-- Table structure for table `clk_visits`
+--
+
+CREATE TABLE `clk_visits` (
+  `object_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+  `visit_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
+  `last_visitdate` int(10) UNSIGNED NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
+
+--
+-- Indexes for dumped tables
+--
+
+--
+-- Indexes for table `clk_answers`
+--
+ALTER TABLE `clk_answers`
+  ADD PRIMARY KEY (`answer_id`),
+  ADD KEY `ix_chdate_answer` (`chdate_answer`),
+  ADD KEY `ix_exercise_id` (`exercise_id`),
+  ADD KEY `ix_chdate_feedback` (`chdate_feedback`),
+  ADD KEY `ix_working_id` (`working_id`);
+
+--
+-- Indexes for table `clk_exercises`
+--
+ALTER TABLE `clk_exercises`
+  ADD PRIMARY KEY (`exercise_id`),
+  ADD KEY `ix_visible` (`visible`),
+  ADD KEY `ix_last_edit` (`deadline`),
+  ADD KEY `ix_user_id` (`user_id`);
+
+--
+-- Indexes for table `clk_extensions`
+--
+ALTER TABLE `clk_extensions`
+  ADD PRIMARY KEY (`extension_id`),
+  ADD KEY `IX_EXERCISE_ID` (`exercise_id`),
+  ADD KEY `IX_USER_ID` (`user_id`),
+  ADD KEY `IX_WORKING_ID` (`working_id`);
+
+--
+-- Indexes for table `clk_file_object`
+--
+ALTER TABLE `clk_file_object`
+  ADD PRIMARY KEY (`file_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_object_Id` (`object_id`),
+  ADD KEY `ix_working_id` (`working_id`);
+
+--
+-- Indexes for table `clk_group_excluded`
+--
+ALTER TABLE `clk_group_excluded`
+  ADD PRIMARY KEY (`course_id`,`group_id`);
+
+--
+-- Indexes for table `clk_options`
+--
+ALTER TABLE `clk_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_course_id` (`course_id`);
+
+--
+-- Indexes for table `clk_peer_options`
+--
+ALTER TABLE `clk_peer_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_relation_id` (`relation_id`);
+
+--
+-- Indexes for table `clk_peer_pairing`
+--
+ALTER TABLE `clk_peer_pairing`
+  ADD PRIMARY KEY (`pairing_id`),
+  ADD KEY `IX_COURSE_ID` (`relation_id`);
+
+--
+-- Indexes for table `clk_peer_review`
+--
+ALTER TABLE `clk_peer_review`
+  ADD PRIMARY KEY (`review_id`),
+  ADD KEY `IX_ANSWER_ID` (`answer_id`),
+  ADD KEY `IX_WORKING_ID` (`working_id`);
+
+--
+-- Indexes for table `clk_rating_text`
+--
+ALTER TABLE `clk_rating_text`
+  ADD PRIMARY KEY (`rating`,`course_id`);
+
+--
+-- Indexes for table `clk_sg_criteria`
+--
+ALTER TABLE `clk_sg_criteria`
+  ADD PRIMARY KEY (`criterium_id`);
+
+--
+-- Indexes for table `clk_sg_evaluations`
+--
+ALTER TABLE `clk_sg_evaluations`
+  ADD PRIMARY KEY (`evaluation_id`);
+
+--
+-- Indexes for table `clk_sg_ratings`
+--
+ALTER TABLE `clk_sg_ratings`
+  ADD PRIMARY KEY (`rating_id`);
+
+--
+-- Indexes for table `clk_sg_userratings`
+--
+ALTER TABLE `clk_sg_userratings`
+  ADD PRIMARY KEY (`userratings_id`);
+
+--
+-- Indexes for table `clk_tan`
+--
+ALTER TABLE `clk_tan`
+  ADD PRIMARY KEY (`tan_id`),
+  ADD KEY `ix_relation_id` (`relation_id`);
+
+--
+-- Indexes for table `clk_topics`
+--
+ALTER TABLE `clk_topics`
+  ADD PRIMARY KEY (`topic_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_course_id` (`course_id`),
+  ADD KEY `ix_visible_at` (`visible`);
+
+--
+-- Indexes for table `clk_topic_excluded`
+--
+ALTER TABLE `clk_topic_excluded`
+  ADD PRIMARY KEY (`topic_id`,`working_id`);
+
+--
+-- Indexes for table `clk_user_options`
+--
+ALTER TABLE `clk_user_options`
+  ADD PRIMARY KEY (`option_id`),
+  ADD KEY `ix_user_id` (`user_id`),
+  ADD KEY `ix_course_id` (`course_id`);
+
+--
+-- Indexes for table `clk_visits`
+--
+ALTER TABLE `clk_visits`
+  ADD PRIMARY KEY (`object_id`,`user_id`,`visit_type`),
+  ADD KEY `ix_last_visit` (`last_visitdate`),
+  ADD KEY `ix_user_id` (`user_id`);
+
+--
+-- AUTO_INCREMENT for dumped tables
+--
+
+--
+-- AUTO_INCREMENT for table `clk_answers`
+--
+ALTER TABLE `clk_answers`
+  MODIFY `answer_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_exercises`
+--
+ALTER TABLE `clk_exercises`
+  MODIFY `exercise_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_extensions`
+--
+ALTER TABLE `clk_extensions`
+  MODIFY `extension_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_file_object`
+--
+ALTER TABLE `clk_file_object`
+  MODIFY `file_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_options`
+--
+ALTER TABLE `clk_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_options`
+--
+ALTER TABLE `clk_peer_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_pairing`
+--
+ALTER TABLE `clk_peer_pairing`
+  MODIFY `pairing_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_peer_review`
+--
+ALTER TABLE `clk_peer_review`
+  MODIFY `review_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_criteria`
+--
+ALTER TABLE `clk_sg_criteria`
+  MODIFY `criterium_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_evaluations`
+--
+ALTER TABLE `clk_sg_evaluations`
+  MODIFY `evaluation_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_ratings`
+--
+ALTER TABLE `clk_sg_ratings`
+  MODIFY `rating_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_sg_userratings`
+--
+ALTER TABLE `clk_sg_userratings`
+  MODIFY `userratings_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_tan`
+--
+ALTER TABLE `clk_tan`
+  MODIFY `tan_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_topics`
+--
+ALTER TABLE `clk_topics`
+  MODIFY `topic_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT for table `clk_user_options`
+--
+ALTER TABLE `clk_user_options`
+  MODIFY `option_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;
+COMMIT;
+
+         */
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 2eb33a815e2..c5a3cf981f1 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -489,6 +489,8 @@ class RouteMap
 
         $group->get('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsShow::class);
         $group->post('/courseware-task-groups', Routes\Courseware\TaskGroupsCreate::class);
+        $group->patch('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsUpdate::class);
+        $group->delete('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsDelete::class);
 
         $group->get('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackShow::class);
         $group->post('/courseware-task-feedback', Routes\Courseware\TaskFeedbackCreate::class);
@@ -525,6 +527,22 @@ class RouteMap
         $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class);
 
         $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class);
+
+        $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class);
+        $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class);
+        $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class);
+
+        $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class);
+        $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class);
+
+        $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class);
+
+        $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class);
+        $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class);
+
+        $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class);
+        $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class);
+        $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class);
     }
 
     private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index 0f837dee3da..d6e3a7db77f 100644
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -8,6 +8,8 @@ use Courseware\BlockFeedback;
 use Courseware\Clipboard;
 use Courseware\Container;
 use Courseware\Instance;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
 use Courseware\StructuralElement;
 use Courseware\StructuralElementComment;
 use Courseware\StructuralElementFeedback;
@@ -23,7 +25,13 @@ use User;
 use Course;
 
 /**
+ * @SuppressWarnings(PHPMD.CamelCaseParameterName)
+ * @SuppressWarnings(PHPMD.CamelCaseVariableName)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.ExcessivePublicCount)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ * @SuppressWarnings(PHPMD.Superglobals)
  * @SuppressWarnings(PHPMD.TooManyMethods)
  * @SuppressWarnings(PHPMD.TooManyPublicMethods)
  */
@@ -306,9 +314,20 @@ class Authority
         return $resource['lecturer_id'] === $user->id;
     }
 
+    public static function canUpdateTaskGroup(User $user, TaskGroup $resource): bool
+    {
+        return self::canCreateTasks($user, $resource->target);
+    }
+
+    public static function canDeleteTaskGroup(User $user, TaskGroup $resource): bool
+    {
+        return self::canUpdateTaskGroup($user, $resource);
+    }
+
     public static function canShowTask(User $user, Task $resource): bool
     {
-        return self::canUpdateTask($user, $resource);
+        return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user)) ||
+            self::canUpdateTask($user, $resource);
     }
 
     public static function canIndexTasks(User $user): bool
@@ -332,6 +351,11 @@ class Authority
         return self::canCreateTasks($user, $resource->structural_element) && !$resource->userIsASolver($user);
     }
 
+    public static function canRenewTask(User $user, Task $resource): bool
+    {
+        return self::canDeleteTask($user, $resource);
+    }
+
     public static function canCreateTaskFeedback(User $user, Task $resource): bool
     {
         return self::canCreateTasks($user, $resource->structural_element);
@@ -352,7 +376,6 @@ class Authority
         return self::canCreateTaskFeedback($user, $resource);
     }
 
-
     public static function canIndexStructuralElementComments(User $user, StructuralElement $resource)
     {
         return self::canShowStructuralElement($user, $resource);
@@ -407,7 +430,8 @@ class Authority
 
     public static function canShowStructuralElementFeedback(User $user, StructuralElementFeedback $resource)
     {
-        return $resource->user_id === $user->id || self::canUpdateStructuralElement($user, $resource->structural_element);
+        return $resource->user_id === $user->id ||
+            self::canUpdateStructuralElement($user, $resource->structural_element);
     }
 
     public static function canDeleteStructuralElementFeedback(User $user, StructuralElementComment $resource)
@@ -415,7 +439,6 @@ class Authority
         return self::canUpdateStructuralElementFeedback($user, $resource);
     }
 
-
     public static function canShowTemplate(User $user, Template $resource)
     {
         // templates are for everybody, aren't they?
@@ -430,7 +453,7 @@ class Authority
 
     public static function canCreateTemplate(User $user)
     {
-        return $GLOBALS['perm']->have_perm('admin');
+        return $GLOBALS['perm']->have_perm('admin', $user->id);
     }
 
     public static function canUpdateTemplate(User $user, Template $resource)
@@ -490,7 +513,7 @@ class Authority
         if ($user->id === $range->id) {
             return true;
         }
-        return $GLOBALS['perm']->have_studip_perm('tutor', $range->id ,$user->id);
+        return $GLOBALS['perm']->have_studip_perm('tutor', $range->id, $user->id);
     }
 
     public static function canUpdateUnit(User $user, Unit $resource): bool
@@ -513,7 +536,6 @@ class Authority
         return $request_user->id === $user->id;
     }
 
-
     public static function canShowClipboard(User $user, Clipboard $resource): bool
     {
         return $resource->user_id === $user->id;
@@ -536,7 +558,7 @@ class Authority
         } else {
             $structural_element = $resource->getStructuralElement();
         }
-        
+
         return $structural_element->canEdit($user);
     }
 
@@ -560,4 +582,89 @@ class Authority
         return $resource->user_id === $user->id;
     }
 
+    public static function canIndexPeerReviewProcesses(User $user): bool
+    {
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId());
+    }
+
+    public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId());
+    }
+
+    public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcess($user, $process->task_group);
+    }
+
+    public static function canIndexPeerReviews(User $user)
+    {
+        // TODO: Reicht das? Werden die in der Route gefiltert? Brauchen das nur Lehrende?
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReview(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->isReviewer($user);
+    }
+
+    public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->isReviewer($user) && !$review->isAnonymous();
+    }
+
+    public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool
+    {
+        if ($review->isReviewer($user)) {
+            return true;
+        }
+
+        $isTutor = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            $review->process->task_group['seminar_id'],
+            $user->getId()
+        );
+
+        return $isTutor && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER;
+    }
+
+    public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canShowPeerReviewProcess($user, $process);
+    }
+
+    public static function canUpdatePeerReview(User $user, PeerReview $review): bool
+    {
+        return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user);
+    }
+
+    public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReview(User $user, PeerReview $review): bool
+    {
+        return self::canCreatePeerReviews($user, $review->process);
+    }
 }
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
new file mode 100644
index 00000000000..8084711d429
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Create a PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesCreate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $taskGroup = $this->getTaskGroupFromJson($json);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreatePeerReviewProcesses($user, $taskGroup)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->create($user, $json);
+
+        return $this->getCreatedResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+        if (self::arrayHas($json, 'data.id')) {
+            return 'New document must not have an `id`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.configuration')) {
+            return 'Missing `configuration` attribute.';
+        }
+        // TODO: validate configuration
+
+        if (!self::arrayHas($json, 'data.attributes.review-start')) {
+            return 'Missing `review-start` attribute.';
+        }
+        $startDate = self::arrayGet($json, 'data.attributes.review-start');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`review-start` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.review-end')) {
+            return 'Missing `review-end` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.review-end');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`review-end` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.relationships.task-group')) {
+            return 'Missing `task-group` relationship.';
+        }
+        if (!$this->getTaskGroupFromJson($json)) {
+            return 'Invalid `task-group` relationship.';
+        }
+    }
+
+    private function getTaskGroupFromJson(array $json): ?TaskGroup
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id');
+
+        return TaskGroup::find($resourceId);
+    }
+
+    private function create(\User $user, array $json): PeerReviewProcess
+    {
+        $taskGroup = $this->getTaskGroupFromJson($json);
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start'));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end'));
+        $configuration = self::arrayGet($json, 'data.attributes.configuration');
+
+        /** @var PeerReviewProcess $process */
+        $process = PeerReviewProcess::create([
+            'task_group_id' => $taskGroup->getId(),
+            'owner_id' => $user->getId(),
+            'configuration' => $configuration,
+            'review_start' => $startDate->getTimestamp(),
+            'review_end' => $endDate->getTimestamp(),
+        ]);
+
+        return $process;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
new file mode 100644
index 00000000000..b9ba42eb3ed
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerPreviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one PeerPreviewProcess.
+ */
+class ProcessesDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerPreviewProcess $resource */
+        $resource = PeerPreviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePeerReviewProcess($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
new file mode 100644
index 00000000000..42bee8a0138
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all visible PeerReviewProcesses.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesIndex extends JsonApiController
+{
+    protected $allowedFilteringParameters = ['cid'];
+
+    protected $allowedIncludePaths = [
+        ProcessSchema::REL_COURSE,
+        ProcessSchema::REL_OWNER,
+        ProcessSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        $this->validateFilters($filtering);
+        $this->authorize($user, $filtering);
+
+        $resources = empty($filtering) ? $this->findAllProcesses($user) : $this->filterProcesses($user, $filtering);
+
+        return $this->getPaginatedContentResponse(
+            array_slice($resources, ...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws BadRequestException
+     */
+    private function validateFilters(array $filtering): void
+    {
+        if (isset($filtering['cid']) && !Course::exists($filtering['cid'])) {
+            throw new BadRequestException('Could not find a course matching this `filter[cid]`.');
+        }
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user, array $filtering): void
+    {
+        if (!Authority::canIndexPeerReviewProcesses($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        if (isset($filtering['cid'])) {
+            if (
+                !CoursesAuthority::canShowCourse(
+                    $user,
+                    Course::find($filtering['cid']),
+                    CoursesAuthority::SCOPE_EXTENDED
+                )
+            ) {
+                throw new AuthorizationFailedException();
+            }
+        }
+    }
+
+    private function findAllProcesses(User $user): iterable
+    {
+        return PeerReviewProcess::findByUser($user);
+    }
+
+    private function filterProcesses(User $user, array $filtering): iterable
+    {
+        if (isset($filtering['cid'])) {
+            /** @var ?\Course $course */
+            $course = \Course::find($filtering['cid']);
+
+            return array_filter(PeerReviewProcess::findByCourse($course), function ($process) use ($user) {
+                return Authority::canShowPeerReviewProcess($user, $process);
+            });
+        }
+
+        return [];
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
new file mode 100644
index 00000000000..3d904217c75
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays one PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        ProcessSchema::REL_COURSE,
+        ProcessSchema::REL_OWNER,
+        ProcessSchema::REL_TASK_GROUP,
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\PeerReviewProcess $resource */
+        $resource = PeerReviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowPeerReviewProcess($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
new file mode 100644
index 00000000000..75ed1b8c47f
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as ProcessSchema;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\PeerReviewProcess $resource */
+        $resource = PeerReviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReviewProcess($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->update($user, $resource, $json);
+
+        return $this->getContentResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.configuration')) {
+            return 'Missing `configuration` attribute.';
+        }
+        // TODO: validate configuration
+
+        if (!self::arrayHas($json, 'data.attributes.review-start')) {
+            return 'Missing `review-start` attribute.';
+        }
+        $startDate = self::arrayGet($json, 'data.attributes.review-start');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`review-start` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.review-end')) {
+            return 'Missing `review-end` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.review-end');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`review-end` is not an ISO 8601 timestamp.';
+        }
+
+        if (self::arrayHas($json, 'data.relationships.task-group')) {
+            if (!$this->getTaskGroupFromJson($json)) {
+                return 'Invalid `task-group` relationship.';
+            }
+        }
+    }
+
+    private function getTaskGroupFromJson(array $json): ?TaskGroup
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.task-group', TaskGroupSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.task-group.data.id');
+
+        return TaskGroup::find($resourceId);
+    }
+
+    private function update(User $user, PeerReviewProcess $process, array $json): PeerReviewProcess
+    {
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-start'));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.review-end'));
+        $configuration = self::arrayGet($json, 'data.attributes.configuration');
+
+        $process->review_start = $startDate->getTimestamp();
+        $process->review_end = $endDate->getTimestamp();
+        $process->configuration = $configuration;
+
+        $process->store();
+
+        return $process;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
new file mode 100644
index 00000000000..4c8441deffa
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use Courseware\Task;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\Task as TaskSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all PeerReviews of a course.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsByTaskIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        // PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?Task $task */
+        $task = Task::find($args['id']);
+        if (!$task) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user);
+
+        $resources = $this->findPeerReviews($task, $user);
+
+        return $this->getPaginatedContentResponse(
+            $resources->limit(...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user): void
+    {
+        if (!Authority::canIndexPeerReviews($user)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findPeerReviews(Task $task, User $user): iterable
+    {
+        return $task->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
new file mode 100644
index 00000000000..26e566d2cc7
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use Courseware\Task;
+use Courseware\TaskGroup;
+use InvalidArgumentException;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\StatusGroup as StatusGroupSchema;
+use JsonApi\Schemas\User as UserSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Statusgruppen;
+use User;
+
+/**
+ * Create a PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $process = $this->getProcessFromJson($json);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreatePeerReviews($user, $process)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resource = $this->create($json);
+
+        return $this->getCreatedResponse($resource);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+        if (self::arrayHas($json, 'data.id')) {
+            return 'New document must not have an `id`.';
+        }
+
+        // process
+        if (!self::arrayHas($json, 'data.relationships.process')) {
+            return 'Missing `process` relationship.';
+        }
+        if (!$this->getProcessFromJson($json)) {
+            return 'Invalid `process` relationship.';
+        }
+
+        // submitter
+        if (!self::arrayHas($json, 'data.relationships.submitter')) {
+            return 'Missing `submitter` relationship.';
+        }
+        if (!$this->getSubmitterFromJson($json)) {
+            return 'Invalid `submitter` relationship.';
+        }
+
+        // reviewer
+        if (!self::arrayHas($json, 'data.relationships.reviewer')) {
+            return 'Missing `reviewer` relationship.';
+        }
+        if (!$this->getReviewerFromJson($json)) {
+            return 'Invalid `reviewer` relationship.';
+        }
+    }
+
+    private function create(array $json): PeerReview
+    {
+        $process = $this->getProcessFromJson($json);
+        $reviewer = $this->getReviewerFromJson($json);
+        $submitter = $this->getSubmitterFromJson($json);
+
+        $task = $process['task_group']->findTaskBySolver($submitter);
+        $reviewerType = $this->getReviewerType($reviewer);
+
+        /** @var PeerReview $review */
+        $review = PeerReview::create([
+            'process_id' => $process->id,
+            'task_id' => $task->id,
+            'submitter_id' => $submitter->id,
+            'reviewer_id' => $reviewer->id,
+            'reviewer_type' => $reviewerType,
+        ]);
+
+        return $review;
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getActorFromJson(array $json, string $relation)
+    {
+        $relationship = 'data.relationships.' . $relation;
+        if (
+            !(
+                $this->validateResourceObject($json, $relationship, UserSchema::TYPE) ||
+                $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE)
+            )
+        ) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, $relationship . '.data.id');
+
+        switch (self::arrayGet($json, $relationship . '.data.type')) {
+            case UserSchema::TYPE:
+                return User::find($resourceId);
+            case StatusGroupSchema::TYPE:
+                return Statusgruppen::find($resourceId);
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    private function getProcessFromJson(array $json): ?PeerReviewProcess
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.process.data.id');
+
+        return PeerReviewProcess::find($resourceId);
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getReviewerFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'reviewer');
+    }
+
+    private function getReviewerType($reviewer): string
+    {
+        if ($reviewer instanceof User) {
+            return 'autor';
+        }
+        if ($reviewer instanceof Statusgruppen) {
+            return 'group';
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getSubmitterFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'submitter');
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
new file mode 100644
index 00000000000..2b7edd60d47
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one PeerPreview.
+ */
+class ReviewsDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerReview $resource */
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePeerReview($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
new file mode 100644
index 00000000000..54fb26baa09
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\Task as TaskSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all PeerReviews of a course.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        // PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_STRUCTURAL_ELEMENT,
+        PeerReviewSchema::REL_TASK . '.' . TaskSchema::REL_TASK_GROUP,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?Course $course */
+        $course = Course::find($args['id']);
+        if (!$course) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user);
+
+        $resources = $this->findPeerReviews($course, $user);
+
+        return $this->getPaginatedContentResponse(
+            array_slice($resources, ...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user): void
+    {
+        if (!Authority::canIndexPeerReviews($user)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findPeerReviews(Course $course, User $user): iterable
+    {
+        return array_filter(PeerReview::findByCourse($course), function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
new file mode 100644
index 00000000000..2a0b04d922e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courses\Authority as CoursesAuthority;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Displays all visible PeerReviewProcesses.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsOfProcessesIndex extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        PeerReviewSchema::REL_PROCESS,
+        PeerReviewSchema::REL_REVIEWER,
+        PeerReviewSchema::REL_SUBMITTER,
+        PeerReviewSchema::REL_TASK,
+    ];
+
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?PeerReviewProcess $process */
+        $process = PeerReviewProcess::find($args['id']);
+        if (!$process) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        $this->authorize($user, $process);
+
+        $resources = $this->findReviews($user, $process);
+
+        return $this->getPaginatedContentResponse(
+            $resources->limit(...$this->getOffsetAndLimit()),
+            count($resources)
+        );
+    }
+
+    /**
+     * @throws AuthorizationFailedException
+     */
+    private function authorize(User $user, PeerReviewProcess $process): void
+    {
+        if (!Authority::canIndexReviewsOfProcesses($user, $process)) {
+            throw new AuthorizationFailedException();
+        }
+    }
+
+    private function findReviews(User $user, PeerReviewProcess $process): iterable
+    {
+        return $process->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
new file mode 100644
index 00000000000..cf3c6002ee6
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReview($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $review = $this->update($resource, $json);
+
+        return $this->getContentResponse($review);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.assessment')) {
+            return 'Missing `assessment` attribute.';
+        }
+
+        // TODO: validate assessment
+    }
+
+    private function update(PeerReview $review, array $json): PeerReview
+    {
+        $review->assessment = self::arrayGet($json, 'data.attributes.assessment');
+        $review->store();
+
+        return $review;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
index 28c4e9ce65c..f7357a43fe4 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
@@ -65,14 +65,20 @@ class TaskGroupsCreate extends JsonApiController
         if (!self::arrayHas($json, 'data.attributes.title')) {
             return 'Missing `title` attribute.';
         }
-        if (!self::arrayHas($json, 'data.attributes.submission-date')) {
-            return 'Missing `submission-date` attribute.';
+        if (!self::arrayHas($json, 'data.attributes.start-date')) {
+            return 'Missing `start-date` attribute.';
         }
-        $submissionDate = self::arrayGet($json, 'data.attributes.submission-date');
-        if (!self::isValidTimestamp($submissionDate)) {
-            return '`submission-date` is not an ISO 8601 timestamp.';
+        $startDate = self::arrayGet($json, 'data.attributes.start-date');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`start-date` is not an ISO 8601 timestamp.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.end-date')) {
+            return 'Missing `end-date` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.end-date');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`end-date` is not an ISO 8601 timestamp.';
         }
-
         if (!self::arrayHas($json, 'data.relationships.target')) {
             return 'Missing `target` relationship.';
         }
@@ -165,8 +171,8 @@ class TaskGroupsCreate extends JsonApiController
         $target = $this->getTargetFromJson($json);
 
         $solverMayAddBlocks = self::arrayGet($json, 'data.attributes.solver-may-add-blocks', '');
-        $submissionDate = self::arrayGet($json, 'data.attributes.submission-date', '');
-        $submissionDate = self::fromISO8601($submissionDate);
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date', ''));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date', ''));
         $title = self::arrayGet($json, 'data.attributes.title', '');
 
         /** @var TaskGroup $taskGroup */
@@ -177,6 +183,8 @@ class TaskGroupsCreate extends JsonApiController
             'task_template_id' => $taskTemplate->getId(),
             'solver_may_add_blocks' => $solverMayAddBlocks,
             'title' => $title,
+            'start_date' => $startDate->getTimestamp(),
+            'end_date' => $endDate->getTimestamp(),
         ]);
 
         foreach ($solvers as $solver) {
@@ -184,7 +192,6 @@ class TaskGroupsCreate extends JsonApiController
                 'task_group_id' => $taskGroup->getId(),
                 'solver_id' => $solver->getId(),
                 'solver_type' => $this->getSolverType($solver),
-                'submission_date' => $submissionDate->getTimestamp(),
             ]);
 
             // copy task template
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
new file mode 100644
index 00000000000..2faf7783bc2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one TaskGroup.
+ */
+class TaskGroupsDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?TaskGroup $resource */
+        $resource = TaskGroup::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeleteTaskGroup($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
index c8ebb86e31b..ff3fba44ecb 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsShow.php
@@ -18,6 +18,7 @@ class TaskGroupsShow extends JsonApiController
     protected $allowedIncludePaths = [
         TaskGroupSchema::REL_COURSE,
         TaskGroupSchema::REL_LECTURER,
+        TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
         TaskGroupSchema::REL_SOLVERS,
         TaskGroupSchema::REL_TARGET,
         TaskGroupSchema::REL_TASK_TEMPLATE,
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
new file mode 100644
index 00000000000..132888e22e4
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one TaskGroup.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class TaskGroupsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\TaskGroup $resource */
+        $resource = TaskGroup::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateTaskGroup($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->update($resource, $json);
+
+        return $this->getContentResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (TaskGroupSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.end-date')) {
+            return 'Missing `review-start` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.end-date');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`end-date` is not an ISO 8601 timestamp.';
+        }
+    }
+
+    private function update(TaskGroup $taskGroup, array $json): TaskGroup
+    {
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date'));
+
+        $taskGroup->end_date = $endDate->getTimestamp();
+
+        $taskGroup->store();
+
+        return $taskGroup;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
index f0b2ce9a53a..47472283859 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
@@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
index 619e7eab9e7..419f9509505 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
@@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware;
 use Courseware\Task;
 use JsonApi\Errors\AuthorizationFailedException;
 use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
 use JsonApi\Schemas\Courseware\Task as TaskSchema;
 use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
 use JsonApi\JsonApiController;
@@ -18,10 +19,13 @@ class TasksShow extends JsonApiController
 {
     protected $allowedIncludePaths = [
         TaskSchema::REL_FEEDBACK,
+        TaskSchema::REL_PEER_REVIEWS,
+        TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS,
         TaskSchema::REL_SOLVER,
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
index 3728dba9a6b..33b51ad1ae8 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
@@ -13,6 +13,8 @@ use Psr\Http\Message\ServerRequestInterface as Request;
 
 /**
  * Update one Task.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class TasksUpdate extends JsonApiController
 {
@@ -32,7 +34,8 @@ class TasksUpdate extends JsonApiController
             throw new RecordNotFoundException();
         }
         $json = $this->validate($request, $resource);
-        if (!Authority::canUpdateTask($user = $this->getUser($request), $resource)) {
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateTask($user, $resource)) {
             throw new AuthorizationFailedException();
         }
         $resource = $this->updateTask($user, $resource, $json);
@@ -66,53 +69,35 @@ class TasksUpdate extends JsonApiController
 
     private function updateTask(\User $user, Task $resource, array $json): Task
     {
-        if (Authority::canDeleteTask($user, $resource)) {
-            if (self::arrayHas($json, 'data.attributes.renewal')) {
-                $newRenewalState = self::arrayGet($json, 'data.attributes.renewal');
-                if ('declined' === $newRenewalState) {
-                    $resource->renewal = $newRenewalState;
-                }
-                if ('granted' === $newRenewalState && self::arrayHas($json, 'data.attributes.renewal-date')) {
-                    $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date', '');
-                    $renewalDate = self::fromISO8601($renewalDate);
+        if (Authority::canRenewTask($user, $resource)) {
+            return $this->renewTask($resource, $json);
+        }
 
-                    $resource->renewal = $newRenewalState;
-                    $resource->renewal_date = $renewalDate->getTimestamp();
-                }
-            }
-        } else {
-            if (self::arrayHas($json, 'data.attributes.submitted')) {
-                $newSubmittedState = self::arrayGet($json, 'data.attributes.submitted');
-                if ($this->canSubmit($resource, $newSubmittedState)) {
-                    $resource->submitted = $newSubmittedState;
-                    if ('pending' === $resource->renewal) {
-                        $resource->renewal = '';
-                    }
-                }
-            }
-            if (self::arrayHas($json, 'data.attributes.renewal')) {
-                $newRenewalState = self::arrayGet($json, 'data.attributes.renewal');
-                if ('pending' === $newRenewalState) {
-                    $resource->renewal = $newRenewalState;
-                }
-            }
+        if (self::arrayGet($json, 'data.attributes.submitted') === true && $resource->canSubmit()) {
+            $resource->submitTask();
         }
 
-        $resource->store();
+        if (self::arrayGet($json, 'data.attributes.renewal') === 'pending') {
+            $resource->requestRenewal();
+        }
 
         return $resource;
     }
 
-    private function canSubmit(Task $resource, string $newSubmittedState): bool
+    private function renewTask(Task $resource, array $json): Task
     {
-        $now = time();
-        if (1 === (int) $resource->submitted || !$newSubmittedState) {
-            return false;
-        }
-        if ('granted' === $resource->renewal) {
-            return $now <= $resource->renewal_date;
-        } else {
-            return $now <= $resource->submission_date;
+        switch (self::arrayGet($json, 'data.attributes.renewal')) {
+            case 'declined':
+                $resource->declineRenewalRequest();
+                break;
+
+            case 'granted':
+                $resource->grantRenewalRequest(
+                    self::fromISO8601(self::arrayGet($json, 'data.attributes.renewal-date'))
+                );
+                break;
         }
+
+        return $resource;
     }
 }
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index dd74bc9bc2a..541c7519723 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -59,17 +59,19 @@ class SchemaMap
             \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class,
             \Courseware\Container::class => Schemas\Courseware\Container::class,
             \Courseware\Instance::class => Schemas\Courseware\Instance::class,
+            \Courseware\PeerReview::class => Schemas\Courseware\PeerReview::class,
+            \Courseware\PeerReviewProcess::class => Schemas\Courseware\PeerReviewProcess::class,
+            \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
             \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class,
             \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class,
             \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class,
-            \Courseware\Unit::class => Schemas\Courseware\Unit::class,
-            \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class,
-            \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class,
             \Courseware\Task::class => Schemas\Courseware\Task::class,
-            \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class,
             \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class,
+            \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class,
             \Courseware\Template::class => Schemas\Courseware\Template::class,
-            \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
+            \Courseware\Unit::class => Schemas\Courseware\Unit::class,
+            \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class,
+            \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class,
         ];
     }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
new file mode 100644
index 00000000000..d8e903cbb68
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReview extends SchemaProvider
+{
+    public const TYPE = 'courseware-peer-reviews';
+
+    public const REL_PROCESS = 'process';
+    public const REL_REVIEWER = 'reviewer';
+    public const REL_SUBMITTER = 'submitter';
+    public const REL_TASK = 'task';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $user = $this->currentUser;
+        $assessment = null;
+        if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) {
+            $assessment = $resource->assessment->getIterator();
+        }
+        return [
+            'assessment' => $assessment,
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships[self::REL_PROCESS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->process),
+            ],
+            self::RELATIONSHIP_DATA => $resource->process,
+        ];
+
+        $reviewer = $resource->getReviewer();
+        $relationships[self::REL_REVIEWER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($reviewer),
+            ],
+            self::RELATIONSHIP_DATA => $reviewer,
+        ];
+
+        $user = $this->currentUser;
+
+        if (Authority::canShowPeerReviewSubmitter($user, $resource)) {
+            $submitter = $resource->getSubmitter();
+            $relationships[self::REL_SUBMITTER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($submitter),
+                ],
+                self::RELATIONSHIP_DATA => $submitter,
+            ];
+        }
+
+        $relationships[self::REL_TASK] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->task),
+            ],
+            self::RELATIONSHIP_DATA => $resource->task,
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php
new file mode 100644
index 00000000000..0eca67c523c
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReviewProcess.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReviewProcess extends SchemaProvider
+{
+    const TYPE = 'courseware-peer-review-processes';
+
+    const REL_COURSE = 'course';
+    const REL_OWNER = 'owner';
+    const REL_PEER_REVIEWS = 'reviews';
+    const REL_TASK_GROUP = 'task-group';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'configuration' => $resource['configuration']->getIterator(),
+            'review-start' => date('c', $resource['review_start']),
+            'review-end' => date('c', $resource['review_end']),
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $course = $resource->getCourse();
+        $relationships[self::REL_COURSE] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($course),
+            ],
+            self::RELATIONSHIP_DATA => $course,
+        ];
+
+        $relationships[self::REL_OWNER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->owner),
+            ],
+            self::RELATIONSHIP_DATA => $resource->owner,
+        ];
+
+        $relationships[self::REL_PEER_REVIEWS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS),
+            ],
+        ];
+
+        $relationships[self::REL_TASK_GROUP] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->task_group),
+            ],
+            self::RELATIONSHIP_DATA => $resource->task_group,
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php
index a87d335b64e..282e6226e14 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/Task.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php
@@ -2,6 +2,8 @@
 
 namespace JsonApi\Schemas\Courseware;
 
+use Courseware\Task as TaskModel;
+use JsonApi\Routes\Courseware\Authority as CoursewareAuthority;
 use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Link;
@@ -11,6 +13,7 @@ class Task extends SchemaProvider
     const TYPE = 'courseware-tasks';
 
     const REL_FEEDBACK = 'task-feedback';
+    const REL_PEER_REVIEWS = 'peer-reviews';
     const REL_SOLVER = 'solver';
     const REL_STRUCTURAL_ELEMENT = 'structural-element';
     const REL_TASK_GROUP = 'task-group';
@@ -28,12 +31,15 @@ class Task extends SchemaProvider
      */
     public function getAttributes($resource, ContextInterface $context): iterable
     {
+        $user = $this->currentUser;
+
         return [
             'progress' => (float) $resource->getTaskProgress(),
             'submission-date' => date('c', $resource['submission_date']),
             'submitted' => (bool) $resource['submitted'],
             'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'],
             'renewal-date' => date('c', $resource['renewal_date']),
+            'can-peer-review' => $resource->userIsAPeerReviewer($user),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
@@ -55,6 +61,12 @@ class Task extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
+        $relationships = $this->addPeerReviews(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_PEER_REVIEWS)
+        );
+
         $relationships[self::REL_SOLVER] = $resource['solver_id']
             ? [
                 self::RELATIONSHIP_LINKS => [
@@ -82,4 +94,30 @@ class Task extends SchemaProvider
 
         return $relationships;
     }
+
+    /**
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    private function addPeerReviews(array $relationships, TaskModel $resource, bool $includeData): array
+    {
+        $relationships[self::REL_PEER_REVIEWS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEWS),
+            ],
+        ];
+
+        if ($includeData) {
+            $data = [];
+            $user = $this->currentUser;
+            if ($resource->isPeerReviewedBy($this->currentUser)) {
+                $data = $resource->peer_reviews->filter(function ($review) use ($user) {
+                    return CoursewareAuthority::canShowPeerReview($user, $review);
+                });
+            }
+
+            $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $data;
+        }
+
+        return $relationships;
+    }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
index 12dbc6c5855..68706640cec 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
@@ -3,6 +3,7 @@
 namespace JsonApi\Schemas\Courseware;
 
 use Courseware\StructuralElement;
+use Courseware\TaskGroup as TaskGroupModel;
 use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Identifier;
@@ -14,6 +15,7 @@ class TaskGroup extends SchemaProvider
 
     const REL_COURSE = 'course';
     const REL_LECTURER = 'lecturer';
+    const REL_PEER_REVIEW_PROCESSES = 'peer-review-processes';
     const REL_SOLVERS = 'solvers';
     const REL_TARGET = 'target';
     const REL_TASK_TEMPLATE = 'task-template';
@@ -35,6 +37,8 @@ class TaskGroup extends SchemaProvider
         return [
             'solver-may-add-blocks' => (bool) $resource['solver_may_add_blocks'],
             'title' => (string) $resource->title,
+            'start-date' => date('c', $resource['start_date']),
+            'end-date' => date('c', $resource['end_date']),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
@@ -65,6 +69,8 @@ class TaskGroup extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
+        $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context);
+
         $relationships[self::REL_SOLVERS] = [
             self::RELATIONSHIP_DATA => $resource->getSolvers(),
         ];
@@ -101,4 +107,19 @@ class TaskGroup extends SchemaProvider
 
         return $relationships;
     }
+
+    private function addPeerReviewProcessesRelationship(iterable $relationships, TaskGroupModel $resource, ContextInterface $context): iterable
+    {
+        $relationships[self::REL_PEER_REVIEW_PROCESSES] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PEER_REVIEW_PROCESSES),
+            ],
+        ];
+
+        if ($this->shouldInclude($context, self::REL_PEER_REVIEW_PROCESSES)) {
+            $relationships[self::REL_PEER_REVIEW_PROCESSES][self::RELATIONSHIP_DATA] = $resource->peer_review_processes;
+        }
+
+        return $relationships;
+    }
 }
diff --git a/lib/models/Courseware/PeerReview.php b/lib/models/Courseware/PeerReview.php
new file mode 100644
index 00000000000..40a684b2714
--- /dev/null
+++ b/lib/models/Courseware/PeerReview.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use DBManager;
+use Statusgruppen;
+use User;
+
+/**
+ * Courseware's peer review instances.
+ *
+ * @since   Stud.IP 5.5
+ */
+class PeerReview extends \SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_peer_reviews';
+
+        $config['serialized_fields']['assessment'] = 'JSONArrayObject';
+
+        $config['belongs_to']['process'] = [
+            'class_name' => PeerReviewProcess::class,
+            'foreign_key' => 'process_id',
+        ];
+        $config['belongs_to']['task'] = [
+            'class_name' => Task::class,
+            'foreign_key' => 'task_id',
+        ];
+        $config['belongs_to']['submitter'] = [
+            'class_name' => \User::class,
+            'foreign_key' => 'submitter_id',
+        ];
+        $config['belongs_to']['reviewer'] = [
+            'class_name' => \User::class,
+            'foreign_key' => 'reviewer_id',
+        ];
+
+        parent::configure($config);
+    }
+
+    /*
+    public static function findByUser(User $user): iterable
+    {
+        $assignments = DBManager::get()->fetchFirst('SELECT id FROM cw_peer_reviews WHERE reviewer_id = ?', [
+            $user->getId(),
+        ]);
+
+        $createdReviews = DBManager::get()->fetchFirst(
+            'SELECT id FROM cw_peer_reviews
+               WHERE process_id IN (
+                 SELECT id FROM cw_peer_review_processes
+                   WHERE owner_id = ?)',
+            [$user->getId()]
+        );
+
+        $ids = array_merge($assignments, $createdReviews);
+
+        return self::findMany($ids);
+    }
+    */
+
+    public static function findByCourse(Course $course): iterable
+    {
+        return self::findMany(self::findIdsByCourse($course->getId()));
+    }
+
+    private static function findIdsByCourse(string $courseId): iterable
+    {
+        return DBManager::get()->fetchFirst(
+            'SELECT id FROM cw_peer_reviews
+                   WHERE process_id in (
+                     SELECT id FROM cw_peer_review_processes
+                       WHERE task_group_id IN (
+                         SELECT id FROM cw_task_groups
+                           WHERE cw_task_groups.seminar_id = ?))',
+            [$courseId]
+        );
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->process->getCourse();
+    }
+
+    public function isAnonymous(): bool
+    {
+        return $this->process->isAnonymous();
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function isReviewer(\User $user): bool
+    {
+        switch ($this->reviewer_type) {
+            case 'autor':
+                return $this->reviewer_id === $user->getId();
+            case 'group':
+                return \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId());
+        }
+    }
+
+    public function getReviewer()
+    {
+        switch ($this->reviewer_type) {
+            case 'autor':
+                return User::find($this->reviewer_id);
+            case 'group':
+                return Statusgruppen::find($this->reviewer_id);
+        }
+    }
+
+    /**
+     * @return User|Statusgruppen
+     */
+    public function getSubmitter()
+    {
+        $user = User::find($this->submitter_id);
+        if ($user) {
+            return $user;
+        }
+
+        $statusGroup = Statusgruppen::find($this->submitter_id);
+        return $statusGroup;
+    }
+}
diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php
new file mode 100644
index 00000000000..51c3c848aee
--- /dev/null
+++ b/lib/models/Courseware/PeerReviewProcess.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use DBManager;
+use SimpleORMapCollection;
+use User;
+
+/**
+ * A PeerReviewProcess groups a set of PeerReviews.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ *
+ * @since   Stud.IP 5.5
+ */
+class PeerReviewProcess extends \SimpleORMap
+{
+    public const DEFAULT_DURATION = 7;
+
+    public const STATE_BEFORE = 'before';
+    public const STATE_ACTIVE = 'active';
+    public const STATE_AFTER = 'after';
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_peer_review_processes';
+
+        $config['serialized_fields']['configuration'] = 'JSONArrayObject';
+
+        $config['belongs_to']['task_group'] = [
+            'class_name' => TaskGroup::class,
+            'foreign_key' => 'task_group_id',
+        ];
+        $config['belongs_to']['owner'] = [
+            'class_name' => User::class,
+            'foreign_key' => 'owner_id',
+        ];
+
+        $config['additional_fields']['peer_reviews'] = [
+            'get' => 'getPeerReviews',
+            'set' => false,
+        ];
+
+        $config['has_many']['_peer_reviews'] = [
+            'class_name' => PeerReview::class,
+            'assoc_foreign_key' => 'process_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        parent::configure($config);
+    }
+
+    public static function findByCourse(Course $course): iterable
+    {
+        return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [
+            DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]),
+        ]);
+    }
+
+    public static function findByUser(User $user): iterable
+    {
+        return self::findMany(
+            DBManager::get()->fetchFirst(
+                'SELECT id FROM cw_peer_review_processes
+                   WHERE task_group_id IN (
+                     SELECT id FROM cw_task_groups
+                       WHERE cw_task_groups.seminar_id IN (
+                         SELECT seminar_id FROM seminar_user WHERE user_id = ?))',
+                [$user->getId()]
+            )
+        );
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->task_group->course;
+    }
+
+    public function getPeerReviews(): SimpleORMapCollection
+    {
+        $this->checkAutomaticPairing();
+
+        return SimpleORMapCollection::createFromArray(
+            PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()])
+        );
+    }
+
+    public function getDuration(): int
+    {
+        if (!isset($this->configuration['duration'])) {
+            return self::DEFAULT_DURATION;
+        }
+
+        return (int) $this->configuration['duration'];
+    }
+
+    public function isAnonymous(): bool
+    {
+        if (!isset($this->configuration['anonymous'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+    public function isAutomaticPairing(): bool
+    {
+        if (!isset($this->configuration['automaticPairing'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+    public function getCurrentState(int $date = null): string
+    {
+        if (is_null($date)) {
+            $date = time();
+        }
+
+        if ($this->review_end < $date) {
+            return self::STATE_AFTER;
+        }
+
+        if ($date < $this->review_start) {
+            return self::STATE_BEFORE;
+        }
+
+        return self::STATE_ACTIVE;
+    }
+
+    public function checkAutomaticPairing(): void
+    {
+        if ($this->isAutomaticPairing() && !$this->paired_at) {
+            $now = time();
+            if ($now > $this->review_start) {
+                $this->createAutomaticPairings();
+                $this->content['paired_at'] = $now;
+                $this->content_db['paired_at'] = $now;
+                $stmt = \DBManager::get()->prepare(
+                    'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?'
+                );
+                $stmt->execute([$now, $this->getId()]);
+            }
+        }
+    }
+
+    public function createAutomaticPairings(): iterable
+    {
+        $taskGroup = $this->task_group;
+        $submitters = $taskGroup->getSubmitters();
+
+        if (count($submitters) < 2) {
+            return [];
+        }
+
+        shuffle($submitters);
+        $copy = $submitters;
+        array_push($copy, array_shift($copy));
+        $pairings = array_map(null, $submitters, $copy);
+
+        return array_map(function ($pairing) use ($taskGroup) {
+            list($submitter, $reviewer) = $pairing;
+            $task = $taskGroup->findTaskBySolver($submitter);
+
+            return PeerReview::create([
+                'process_id' => $this->getId(),
+                'task_id' => $task->getId(),
+                'submitter_id' => $submitter->getId(),
+                'reviewer_id' => $reviewer->getId(),
+                'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group',
+            ]);
+        }, $pairings);
+    }
+
+    public function rescheduleTo(int $newStartDate): void
+    {
+        $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60);
+        $this->setData([
+            "review_start" => $newStartDate,
+            "review_end" => $newEndDate,
+        ]);
+        $this->store();
+    }
+}
diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php
index 940547ec65c..a8b34252484 100644
--- a/lib/models/Courseware/StructuralElement.php
+++ b/lib/models/Courseware/StructuralElement.php
@@ -389,7 +389,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject
                         return true;
                     }
 
-                    return $task->userIsASolver($user);
+                    return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user);
                 }
 
                 if ($this->canEdit($user)) {
diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php
index 3a68d3e4ffe..ca416332824 100644
--- a/lib/models/Courseware/Task.php
+++ b/lib/models/Courseware/Task.php
@@ -31,7 +31,9 @@ use User;
  * @property \Statusgruppen $group belongs_to \Statusgruppen
  * @property \Course $course belongs_to \Course
  * @property TaskFeedback|null $task_feedback belongs_to TaskFeedback
- * @property mixed $solver additional field
+ * @property-read \User|\Statusgruppen|null $solver additional field
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class Task extends \SimpleORMap
 {
@@ -76,10 +78,22 @@ class Task extends \SimpleORMap
             'foreign_key' => 'feedback_id',
         ];
 
+        $config['has_many']['peer_reviews'] = [
+            'class_name' => PeerReview::class,
+            'assoc_foreign_key' => 'task_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
         $config['additional_fields']['solver'] = [
             'get' => 'getSolver',
             'set' => false,
         ];
+        $config['additional_fields']['submission_date'] = [
+            'get' => 'getSubmissionDate',
+            'set' => false,
+        ];
 
         parent::configure($config);
     }
@@ -155,6 +169,14 @@ class Task extends \SimpleORMap
         return false;
     }
 
+    /**
+     * @param \User|\Seminar_User $user
+     */
+    public function userIsAPeerReviewer($user): bool
+    {
+        return $this->isPeerReviewed() && $this->isPeerReviewedBy($user);
+    }
+
     /**
      * @return \User|\Statusgruppen|null the solver
      */
@@ -171,6 +193,11 @@ class Task extends \SimpleORMap
         return null;
     }
 
+    public function getSubmissionDate(): int
+    {
+        return $this->task_group['end_date'];
+    }
+
     public function getTaskProgress(): float
     {
         $children = $this->structural_element->findDescendants();
@@ -185,6 +212,73 @@ class Task extends \SimpleORMap
         return $progress * 100;
     }
 
+    public function canSubmit(): bool
+    {
+        return !$this->submitted &&
+            time() <= ('granted' === $this->renewal ? $this->renewal_date : $this->submission_date);
+    }
+
+    public function submitTask(): void
+    {
+        $this->submitted = 1;
+        if ('pending' === $this->renewal) {
+            $this->renewal = '';
+        }
+        $this->store();
+
+        // $this->task_group->notifyTaskDidSubmit($this);
+    }
+
+    public function isRenewed(): bool
+    {
+        return $this->renewal === 'granted';
+    }
+
+    public function requestRenewal(): void
+    {
+        $this->renewal = 'pending';
+        $this->store();
+    }
+
+    public function declineRenewalRequest(): void
+    {
+        $this->renewal = 'declined';
+        $this->store();
+    }
+
+    public function grantRenewalRequest(\DateTime $renewalDate): void
+    {
+        $this->renewal = 'granted';
+        $this->renewal_date = $renewalDate->getTimestamp();
+        $this->store();
+    }
+
+    public function isPeerReviewed(): bool
+    {
+        return PeerReview::countBySql('task_id = ?', [$this->getId()]) !== 0;
+    }
+
+    /**
+     * @param \User|\Seminar_User $user
+     */
+    public function isPeerReviewedBy($user): bool
+    {
+        $userId = $user->getId();
+        $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"';
+        if (PeerReview::countBySql($sql, [$this->getId(), $userId]) !== 0) {
+            return true;
+        }
+
+        $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"';
+        foreach (\DBManager::get()->fetchFirst($sql, [$this->getId()]) as $reviewerId) {
+            if (\Statusgruppen::isMemberOf($reviewerId, $userId)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     private function getStructuralElementProgress(StructuralElement $structural_element): float
     {
         $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]);
diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php
index 092edf644a4..19f91ed9084 100644
--- a/lib/models/Courseware/TaskGroup.php
+++ b/lib/models/Courseware/TaskGroup.php
@@ -2,6 +2,7 @@
 
 namespace Courseware;
 
+use Statusgruppen;
 use User;
 
 /**
@@ -24,6 +25,11 @@ use User;
  * @property \SimpleORMapCollection|Task[] $tasks has_many Task
  * @property \User $lecturer belongs_to \User
  * @property \Course $course belongs_to \Course
+ * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement
+ * @property \SimpleORMapCollection $tasks has_many Courseware\Task
+ * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class TaskGroup extends \SimpleORMap implements \PrivacyObject
 {
@@ -41,6 +47,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             'foreign_key' => 'seminar_id',
         ];
 
+        $config['belongs_to']['target'] = [
+            'class_name' => StructuralElement::class,
+            'foreign_key' => 'target_id',
+        ];
+
         $config['has_many']['tasks'] = [
             'class_name' => Task::class,
             'assoc_foreign_key' => 'task_group_id',
@@ -49,9 +60,35 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             'order_by' => 'ORDER BY mkdate',
         ];
 
+        $config['has_many']['peer_review_processes'] = [
+            'class_name' => PeerReviewProcess::class,
+            'assoc_foreign_key' => 'task_group_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        $config['registered_callbacks']['after_store'][] = 'cbAfterStore';
+
         parent::configure($config);
     }
 
+    /**
+     * Export available data of a given user into a storage object
+     * (an instance of the StoredUserData class) for that user.
+     *
+     * @param StoredUserData $storage object to store data into
+     */
+    public static function exportUserData(\StoredUserData $storage)
+    {
+        $task_groups = \DBManager::get()->fetchAll('SELECT * FROM cw_task_groups WHERE lecturer_id = ?', [
+            $storage->user_id,
+        ]);
+        if ($task_groups) {
+            $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups);
+        }
+    }
+
     public function getSolvers(): iterable
     {
         $solvers = $this->tasks->pluck('solver');
@@ -59,21 +96,68 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
         return $solvers;
     }
 
+    public function getSubmitters(): iterable
+    {
+        return \DBManager::get()->fetchAll(
+            'SELECT solver_id, solver_type FROM cw_tasks WHERE task_group_id = ? AND submitted = 1',
+            [$this->getId()],
+            function ($row) {
+                switch ($row['solver_type']) {
+                    case 'autor':
+                        return \User::find($row['solver_id']);
+                    case 'group':
+                        return \Statusgruppen::find($row['solver_id']);
+                }
+            }
+        );
+    }
+
+    public function hasPeerReviewProcesses(): bool
+    {
+        return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0;
+    }
+
+    // public function notifyTaskDidSubmit(Task $task): void
+    // {
+    //     if ($this->hasPeerReviewProcesses()) {
+    //         foreach ($this->peer_review_processes as $process) {
+    //             $process->handleTaskSubmitted($task);
+    //         }
+    //     }
+    // }
+
     /**
-     * Export available data of a given user into a storage object
-     * (an instance of the StoredUserData class) for that user.
+     * @param User|Statusgruppen $solver
      *
-     * @param StoredUserData $storage object to store data into
+     * @return Task|null
      */
-    public static function exportUserData(\StoredUserData $storage)
+    public function findTaskBySolver($solver)
     {
-        $task_groups = \DBManager::get()->fetchAll(
-            'SELECT * FROM cw_task_groups WHERE lecturer_id = ?',
-            [$storage->user_id]
+        $row = \DBManager::get()->fetchOne(
+            'SELECT id FROM cw_tasks WHERE task_group_id = ? AND solver_id = ? AND solver_type = ?',
+            [
+                $this->getId(),
+                $solver->getId(),
+                $solver instanceof User ? 'autor' : 'group',
+            ]
         );
-        if ($task_groups) {
-            $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups);
+
+        return empty($row) ? null : Task::find($row['id']);
+    }
+
+    public function cbAfterStore(): void
+    {
+        if ($this->isFieldDirty('end_date')) {
+            $this->reschedulePeerReviewProcesses();
+        }
+    }
+
+    private function reschedulePeerReviewProcesses(): void
+    {
+        if ($this->hasPeerReviewProcesses()) {
+            foreach ($this->peer_review_processes as $process) {
+                $process->rescheduleTo($this->end_date);
+            }
         }
-        
     }
 }
diff --git a/lib/models/Statusgruppen.php b/lib/models/Statusgruppen.php
index b4a0b69e855..d726a1e5160 100644
--- a/lib/models/Statusgruppen.php
+++ b/lib/models/Statusgruppen.php
@@ -728,4 +728,17 @@ class Statusgruppen extends SimpleORMap implements PrivacyObject
             }
         }
     }
+
+    /**
+     * Checks if a user is a member of a group.
+     *
+     * @param string $user_id The user id
+     * @return boolean <b>true</b> if user is a member of this group
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public static function isMemberOf(string $gruppenId, string $userId): bool
+    {
+        return StatusgruppeUser::countBySql('statusgruppe_id = ? AND user_id = ?', [$gruppenId, $userId]) !== 0;
+    }
 }
diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php
index 6766f8ce387..e48f8b99188 100644
--- a/lib/modules/CoursewareModule.class.php
+++ b/lib/modules/CoursewareModule.class.php
@@ -71,6 +71,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
             'tasks',
             new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId)
         );
+        $navigation->addSubNavigation(
+            'peer-review',
+            new Navigation(_('Peer-Reviews'), 'dispatch.php/course/courseware/tasks/peer-review-processes?cid=' . $courseId)
+        );
         $navigation->addSubNavigation(
             'comments',
             new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId)
diff --git a/package.json b/package.json
index 3e8939b1a14..b99f9e58aee 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,9 @@
         "webpack-dev": "webpack --config webpack.dev.js --mode development",
         "webpack-prod": "webpack --config webpack.prod.js --mode production",
         "webpack-watch": "webpack --config webpack.dev.js --mode development --watch",
-        "test": "jest tests/jest/"
+        "test": "jest tests/jest/",
+        "storybook": "start-storybook -p 6006",
+        "build-storybook": "build-storybook"
     },
     "author": "",
     "license": "GPL-2.0",
@@ -67,6 +69,14 @@
         "@johmun/vue-tags-input": "^2.1.0",
         "@playwright/test": "^1.33.0",
         "@popperjs/core": "^2.11.2",
+        "@storybook/addon-actions": "^6.5.13",
+        "@storybook/addon-essentials": "^6.5.13",
+        "@storybook/addon-interactions": "^6.5.13",
+        "@storybook/addon-links": "^6.5.13",
+        "@storybook/builder-webpack5": "^6.5.13",
+        "@storybook/manager-webpack5": "^6.5.13",
+        "@storybook/testing-library": "^0.0.13",
+        "@storybook/vue": "^6.5.13",
         "@types/jquery": "^3.5.16",
         "@types/jqueryui": "^1.12.16",
         "@types/lodash": "^4.14.191",
@@ -87,6 +97,7 @@
         "easygettext": "^2.17.0",
         "es6-promise": "4.2.8",
         "eslint": "^7.32.0",
+        "eslint-plugin-storybook": "^0.6.6",
         "eslint-plugin-vue": "^9.10.0",
         "eslint-webpack-plugin": "^3.1.1",
         "expose-loader": "1.0.1",
@@ -135,7 +146,7 @@
         "vue": "^2.6.12",
         "vue-dragscroll": "^3.0.1",
         "vue-gettext": "^2.1.12",
-        "vue-loader": "^15.9.8",
+        "vue-loader": "^15.10.1",
         "vue-router": "^3.5.1",
         "vue-select": "^3.11.2",
         "vue-template-babel-compiler": "^1.2.0",
diff --git a/resources/assets/javascripts/lib/gettext.ts b/resources/assets/javascripts/lib/gettext.ts
new file mode 100644
index 00000000000..23daaaa075e
--- /dev/null
+++ b/resources/assets/javascripts/lib/gettext.ts
@@ -0,0 +1,114 @@
+import { translate } from 'vue-gettext';
+import * as defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json';
+import eventBus from './event-bus';
+
+interface StringDict {
+    [key: string]: string;
+}
+
+interface InstalledLanguage {
+    name: string;
+    selected: boolean;
+}
+
+interface InstalledLanguages {
+    [key: string]: InstalledLanguage;
+}
+
+type TranslationDict = StringDict;
+
+interface TranslationDicts {
+    [key: string]: TranslationDict | null;
+}
+
+const DEFAULT_LANG = 'de_DE';
+const DEFAULT_LANG_NAME = 'Deutsch';
+
+const state = getInitialState();
+
+const $gettext = translate.gettext.bind(translate);
+const $ngettext = translate.ngettext.bind(translate);
+const $gettextInterpolate = translate.gettextInterpolate.bind(translate);
+
+export { $gettext, $ngettext, $gettextInterpolate, translate, getLocale, setLocale, getVueConfig };
+
+function getLocale() {
+    return state.locale;
+}
+
+async function setLocale(locale = getInitialLocale()) {
+    if (!(locale in getInstalledLanguages())) {
+        throw new Error('Invalid locale: ' + locale);
+    }
+
+    state.locale = locale;
+    if (state.translations[state.locale] === null) {
+        const translations: TranslationDict = await getTranslations(state.locale);
+        state.translations[state.locale] = translations;
+    }
+
+    translate.initTranslations(state.translations, {
+        getTextPluginMuteLanguages: [DEFAULT_LANG],
+        getTextPluginSilent: false,
+        language: state.locale,
+        silent: false,
+    });
+
+    eventBus.emit('studip:set-locale', state.locale);
+}
+
+function getVueConfig() {
+    const availableLanguages = Object.entries(getInstalledLanguages()).reduce((memo, [lang, { name }]) => {
+        memo[lang] = name;
+
+        return memo;
+    }, {} as StringDict);
+
+    return {
+        availableLanguages,
+        defaultLanguage: DEFAULT_LANG,
+        muteLanguages: [DEFAULT_LANG],
+        silent: false,
+        translations: state.translations,
+    };
+}
+
+function getInitialState() {
+    const translations: TranslationDicts = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => {
+        memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null;
+
+        return memo;
+    }, {} as TranslationDicts);
+
+    return {
+        locale: DEFAULT_LANG,
+        translations,
+    };
+}
+
+function getInitialLocale() {
+    for (const [lang, { selected }] of Object.entries(getInstalledLanguages())) {
+        if (selected) {
+            return lang;
+        }
+    }
+
+    return DEFAULT_LANG;
+}
+
+function getInstalledLanguages(): InstalledLanguages {
+    return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } };
+}
+
+async function getTranslations(locale: string): Promise<TranslationDict> {
+    try {
+        const language = locale.split(/[_-]/)[0];
+        const translation = await import(`../../../../locale/${language}/LC_MESSAGES/js-resources.json`);
+
+        return translation;
+    } catch (exception) {
+        console.error('Could not load locale: "' + locale + '"', exception);
+
+        return {};
+    }
+}
diff --git a/resources/stories/ActionMenu.stories.js b/resources/stories/ActionMenu.stories.js
new file mode 100644
index 00000000000..c1f2f64b2f8
--- /dev/null
+++ b/resources/stories/ActionMenu.stories.js
@@ -0,0 +1,34 @@
+import StudipActionMenu from '../vue/components/StudipActionMenu.vue';
+
+export default {
+    title: 'Stud.IP/ActionMenu',
+    component: StudipActionMenu,
+    argTypes: {
+    },
+};
+
+const ActionMenuStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { StudipActionMenu },
+    template: `<StudipActionMenu v-bind="$props"></StudipActionMenu>`,
+});
+
+export const SimpleActionMenuStory = ActionMenuStory.bind({});
+SimpleActionMenuStory.args = {
+    items: [
+        {
+            id: 1,
+            attributes: {},
+            classes: '',
+            disabled: false,
+            emit: false,
+            emitArguments: [],
+            icon: 'progress',
+            label: 'label',
+            name: null,
+            type: 'link',
+            url: '#',
+        },
+    ],
+};
+SimpleActionMenuStory.storyName = 'Exceptions';
diff --git a/resources/stories/ContentBox.stories.js b/resources/stories/ContentBox.stories.js
new file mode 100644
index 00000000000..02f747b2f6f
--- /dev/null
+++ b/resources/stories/ContentBox.stories.js
@@ -0,0 +1,30 @@
+// StudipContentBox.vue
+import StudipContentBox from '../vue/components/StudipContentBox.vue';
+
+export default {
+    title: 'Stud.IP/ContentBox',
+    component: StudipContentBox,
+    argTypes: {
+        type: {},
+    },
+};
+
+const ContentBoxStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { StudipContentBox },
+    template: `<StudipContentBox v-bind="$props" :details="details">Hallo Welt</StudipContentBox>`,
+});
+
+export const SimpleBox = ContentBoxStory.bind({});
+SimpleBox.args = {
+    icon: 'group2',
+    items: [
+        {
+            icon: 'accept',
+            id: 1,
+            label: 'title',
+        },
+    ],
+    title: 'Simple ContentBox',
+};
+SimpleBox.storyName = 'Simple ContentBox';
diff --git a/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js b/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js
new file mode 100644
index 00000000000..15f2070b8b9
--- /dev/null
+++ b/resources/stories/Courseware/Tasks/PeerReviewEditorForm.stories.js
@@ -0,0 +1,25 @@
+import EditorForm from '../../../vue/components/courseware/tasks/peer-review/EditorForm.vue';
+
+export default {
+    title: 'Stud.IP/Courseware/Aufgaben/PeerReview/Editor',
+    component: EditorForm,
+    argTypes: {
+        onCancel: { action: 'cancelled' },
+        onSave: { action: 'saved' },
+    },
+};
+
+const Template = (args, { argTypes }) => ({
+    components: { EditorForm },
+    props: Object.keys(argTypes),
+    template: '<EditorForm v-bind="$props" @save="onSave" @cancel="onCancel" />',
+});
+
+export const EditorFormStory = Template.bind({});
+EditorFormStory.args = {
+    criteria: [
+        { text: 'foo', description: 'bar' },
+        { text: 'baz', description: 'bam' },
+    ],
+};
+EditorFormStory.storyName = 'Peer-Review Editor für Formularformat';
diff --git a/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js b/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js
new file mode 100644
index 00000000000..7128f8ff49d
--- /dev/null
+++ b/resources/stories/Courseware/Tasks/PeerReviewEditorTable.stories.js
@@ -0,0 +1,25 @@
+import EditorTable from '../../../vue/components/courseware/tasks/peer-review/EditorTable.vue';
+
+export default {
+    title: 'Stud.IP/Courseware/Aufgaben/PeerReview/Editor',
+    component: EditorTable,
+    argTypes: {
+        onCancel: { action: 'cancelled' },
+        onSave: { action: 'saved' },
+    },
+};
+
+const Template = (args, { argTypes }) => ({
+    components: { EditorTable },
+    props: Object.keys(argTypes),
+    template: '<EditorTable v-bind="$props" @save="onSave" @cancel="onCancel" />',
+});
+
+export const EditorTableStory = Template.bind({});
+EditorTableStory.args = {
+    criteria: [
+        { text: 'foo' },
+        { text: 'bar' },
+    ],
+};
+EditorTableStory.storyName = 'Peer-Review Editor für Tabellenformat';
diff --git a/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js b/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js
new file mode 100644
index 00000000000..32329fe25f9
--- /dev/null
+++ b/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js
@@ -0,0 +1,99 @@
+import PeerReviewProcessCreateDialog from '../../../vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue';
+
+export default {
+    title: 'Stud.IP/Courseware/Aufgaben/PeerReview',
+    component: PeerReviewProcessCreateDialog,
+    argTypes: {
+        type: {},
+    },
+};
+
+const PeerReviewProcessCreateDialogStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { PeerReviewProcessCreateDialog },
+    template: `<PeerReviewProcessCreateDialog v-bind="$props"></PeerReviewProcessCreateDialog>`,
+});
+
+export const Dialog = PeerReviewProcessCreateDialogStory.bind({});
+Dialog.args = {
+    taskGroup: getTaskGroup(),
+};
+Dialog.storyName = 'Peer-Review-Prozess-Dialog';
+
+function getTaskGroup() {
+    return {
+        type: 'courseware-task-groups',
+        id: '1',
+        attributes: {
+            'solver-may-add-blocks': true,
+            title: 'Aufgabe a',
+            'start-date': '2023-06-14T06:40:16+02:00',
+            'end-date': '2023-07-21T20:30:40+02:00',
+            mkdate: '2023-06-14T06:40:16+02:00',
+            chdate: '2023-06-14T06:40:16+02:00',
+        },
+        relationships: {
+            course: {
+                links: {
+                    related: '/jsonapi.php/v1/courses/a07535cf2f8a72df33c12ddfa4b53dde',
+                },
+                data: {
+                    type: 'courses',
+                    id: 'a07535cf2f8a72df33c12ddfa4b53dde',
+                },
+            },
+            lecturer: {
+                links: {
+                    related: '/jsonapi.php/v1/users/205f3efb7997a0fc9755da2b535038da',
+                },
+                data: {
+                    type: 'users',
+                    id: '205f3efb7997a0fc9755da2b535038da',
+                },
+            },
+            'peer-review-processes': {
+                links: {
+                    related: '/jsonapi.php/v1/courseware-task-groups/1/peer-review-processes',
+                },
+                data: [],
+            },
+            solvers: {
+                data: [
+                    {
+                        type: 'users',
+                        id: 'e7a0a84b161f3e8c09b4a0a2e8a58147',
+                    },
+                ],
+            },
+            target: {
+                links: {
+                    related: '/jsonapi.php/v1/courseware-structural-elements/1',
+                },
+                data: {
+                    type: 'courseware-structural-elements',
+                    id: '1',
+                },
+            },
+            'task-template': {
+                links: {
+                    related: '/jsonapi.php/v1/courseware-structural-elements/5',
+                },
+                data: {
+                    type: 'courseware-structural-elements',
+                    id: '5',
+                },
+            },
+            tasks: {
+                data: [
+                    {
+                        type: 'courseware-tasks',
+                        id: '1',
+                    },
+                ],
+            },
+        },
+        links: {
+            self: '/jsonapi.php/v1/courseware-task-groups/1',
+        },
+    };
+}
diff --git a/resources/stories/DatePicker.stories.js b/resources/stories/DatePicker.stories.js
new file mode 100644
index 00000000000..d120de75b5f
--- /dev/null
+++ b/resources/stories/DatePicker.stories.js
@@ -0,0 +1,28 @@
+import DatePicker from '../vue/components/DatePicker.vue';
+
+export default {
+    title: 'Stud.IP/DatePicker',
+    component: DatePicker,
+    argTypes: {},
+};
+
+const DatePickerStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { DatePicker },
+    template: `<DatePicker v-bind="$props"></DatePicker>`,
+});
+
+function tonight() {
+    const tonight = new Date();
+    tonight.setHours(0);
+    tonight.setMinutes(0);
+    return tonight;
+}
+
+export const SimpleDatePickerStory = DatePickerStory.bind({});
+SimpleDatePickerStory.args = {
+    name: 'SimpleDatePicker',
+    value: new Date() / 1000,
+    mindate: tonight(),
+};
+SimpleDatePickerStory.storyName = 'Date-Picker';
diff --git a/resources/stories/DateTime.stories.js b/resources/stories/DateTime.stories.js
new file mode 100644
index 00000000000..b46e680b369
--- /dev/null
+++ b/resources/stories/DateTime.stories.js
@@ -0,0 +1,27 @@
+import StudipDateTime from '../vue/components/StudipDateTime.vue';
+
+export default {
+    title: 'Stud.IP/DateTime',
+    component: StudipDateTime,
+    argTypes: {
+    },
+};
+
+const DateTimeStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { StudipDateTime },
+    template: `<StudipDateTime v-bind="$props"></StudipDateTime>`,
+});
+
+export const SimpleDateTimeStory = DateTimeStory.bind({});
+SimpleDateTimeStory.args = {
+    timestamp: Math.floor((new Date()) / 1000),
+};
+SimpleDateTimeStory.storyName = 'Einfache Zeit';
+
+export const RelativeDateTimeStory = DateTimeStory.bind({});
+RelativeDateTimeStory.args = {
+    relative: true,
+    timestamp: Math.floor((new Date()) / 1000),
+};
+RelativeDateTimeStory.storyName = 'Relative Zeit';
diff --git a/resources/stories/Icon.stories.js b/resources/stories/Icon.stories.js
new file mode 100644
index 00000000000..85d1dd3401f
--- /dev/null
+++ b/resources/stories/Icon.stories.js
@@ -0,0 +1,27 @@
+import StudipIcon from '../vue/components/StudipIcon.vue';
+
+export default {
+    title: 'Stud.IP/Icon',
+    component: StudipIcon,
+    argTypes: {
+    },
+    parameters: {
+        docs: {
+            page: null,
+        },
+    },
+};
+
+const IconStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { StudipIcon },
+    template: `<StudipIcon v-bind="$props"></StudipIcon>`,
+});
+
+export const SimpleIconStory = IconStory.bind({});
+SimpleIconStory.args = {
+    shape: "progress",
+    role: "clickable",
+    size: 32,
+};
+SimpleIconStory.storyName = 'Einfaches Icon';
diff --git a/resources/stories/Icons-Documentations.stories.mdx b/resources/stories/Icons-Documentations.stories.mdx
new file mode 100644
index 00000000000..b89cf0a2b05
--- /dev/null
+++ b/resources/stories/Icons-Documentations.stories.mdx
@@ -0,0 +1,58 @@
+# Replacing DocsPage with custom `MDX` content
+
+This file is a documentation-only `MDX`file to customize Storybook's [DocsPage](https://storybook.js.org/docs/react/writing-docs/docs-page#replacing-docspage).
+
+It can be further expanded with your own code snippets and include specific information related to your stories.
+
+For example:
+
+import { Story } from "@storybook/addon-docs";
+
+## Button
+
+Button is the primary component. It has four possible states.
+
+- [Primary](#primary)
+- [Secondary](#secondary)
+- [Large](#large)
+- [Small](#small)
+
+## With the story title defined
+
+If you included the title in the story's default export, use this approach.
+
+### Primary
+
+<Story id="example-button--primary" />
+
+### Secondary
+
+<Story id="example-button--secondary" />
+
+### Large
+
+<Story id="example-button--large" />
+
+### Small
+
+<Story id="example-button--small" />
+
+## Without the story title defined
+
+If you didn't include the title in the story's default export, use this approach.
+
+### Primary
+
+<Story id="your-directory-button--primary"/>
+
+### Secondary
+
+<Story id="your-directory-button--secondary"/>
+
+### Large
+
+<Story id="your-directory-button--large"/>
+
+### Small
+
+<Story id="your-directory-button--small" />
diff --git a/resources/stories/MessageBox.stories.js b/resources/stories/MessageBox.stories.js
new file mode 100644
index 00000000000..b08c3cc39de
--- /dev/null
+++ b/resources/stories/MessageBox.stories.js
@@ -0,0 +1,39 @@
+// StudipMessageBox.vue
+import StudipMessageBox from '../vue/components/StudipMessageBox.vue';
+
+export default {
+    title: 'Stud.IP/MessageBox',
+    component: StudipMessageBox,
+    argTypes: {
+        type: {
+            control: { type: 'select' },
+            options: ['exception', 'error', 'warning', 'success', 'info'],
+        },
+    },
+};
+
+const MessageBoxStory = (args, { argTypes }) => ({
+    props: Object.keys(argTypes),
+    components: { StudipMessageBox },
+    template: `<StudipMessageBox v-bind="$props" :details="details">Hallo Welt</StudipMessageBox>`,
+});
+
+export const ExceptionBox = MessageBoxStory.bind({});
+ExceptionBox.args = { type: 'exception' };
+ExceptionBox.storyName = 'Exceptions';
+
+export const ErrorBox = MessageBoxStory.bind({});
+ErrorBox.args = { type: 'error' };
+ErrorBox.storyName = 'Error';
+
+export const WarningBox = MessageBoxStory.bind({});
+WarningBox.args = { type: 'warning' };
+WarningBox.storyName = 'Warning';
+
+export const SuccessBox = MessageBoxStory.bind({});
+SuccessBox.args = { type: 'success' };
+SuccessBox.storyName = 'Success';
+
+export const InfoBox = MessageBoxStory.bind({});
+InfoBox.args = { type: 'info' };
+InfoBox.storyName = 'Info';
diff --git a/resources/vue-gettext.d.ts b/resources/vue-gettext.d.ts
new file mode 100644
index 00000000000..b3f4c6611cb
--- /dev/null
+++ b/resources/vue-gettext.d.ts
@@ -0,0 +1,17 @@
+declare module "vue-gettext" {
+    import GettextPlugin from 'vue-gettext';
+
+    declare namespace translate {
+        function getTranslation(msgid: any, n?: number, context?: any, defaultPlural?: any, language?: string): any;
+        function gettext(msgid: any, language?: string): any;
+        function pgettext(context: any, msgid: any, language?: string): any;
+        function ngettext(msgid: any, plural: any, n: any, language?: string): any;
+        function npgettext(context: any, msgid: any, plural: any, n: any, language?: string): any;
+        function initTranslations(translations: any, config: any): void;
+        const gettextInterpolate: any;
+    }
+
+    export { translate };
+
+    export default GettextPlugin;
+}
diff --git a/resources/vue/components/DatePicker.vue b/resources/vue/components/DatePicker.vue
new file mode 100644
index 00000000000..90a412f59e6
--- /dev/null
+++ b/resources/vue/components/DatePicker.vue
@@ -0,0 +1,78 @@
+<template>
+    <span>
+        <input type="hidden" :name="name" :value="value" />
+        <input
+            type="text"
+            ref="visibleInput"
+            class="visible_input"
+            @change="setDate"
+            v-bind="$attrs"
+            v-on="$listeners"
+        />
+    </span>
+</template>
+
+<script>
+function midnight(tonight = new Date()) {
+    tonight.setHours(0);
+    tonight.setMinutes(0);
+    return tonight;
+}
+
+export default {
+    inheritAttrs: false,
+    props: {
+        name: {
+            type: String,
+            required: false,
+        },
+        value: {
+            type: Date,
+            required: false,
+        },
+        mindate: {
+            type: Date,
+            required: false,
+        },
+        maxdate: {
+            type: Date,
+            required: false,
+        },
+    },
+    methods: {
+        setDate() {
+            const date = $(this.$refs.visibleInput).datepicker('getDate');
+            this.$emit('input', date);
+        },
+    },
+    mounted() {
+        if (!this.value) {
+            this.value = midnight();
+        }
+        const that = this;
+        $(this.$refs.visibleInput)
+            .datepicker({
+                onSelect: function () {
+                    that.setDate();
+                },
+                minDate: this.mindate ?? null,
+                maxDate: this.maxdate ?? null,
+            })
+            .datepicker('setDate', this.value);
+    },
+    beforeDestroy() {
+        $(this.$refs.visibleInput).datepicker('destroy');
+    },
+    watch: {
+        value(newValue) {
+            $(this.$refs.visibleInput).datepicker('setDate', newValue);
+        },
+        mindate(newValue) {
+            $(this.$refs.visibleInput).datepicker('option', 'minDate', newValue);
+        },
+        maxdate(newValue) {
+            $(this.$refs.visibleInput).datepicker('option', 'maxDate', newValue);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/Datetimepicker.vue b/resources/vue/components/Datetimepicker.vue
index 6b12ad78abc..e8b0789da5b 100644
--- a/resources/vue/components/Datetimepicker.vue
+++ b/resources/vue/components/Datetimepicker.vue
@@ -11,6 +11,10 @@
 </template>
 
 <script>
+function convertDate(date) {
+    return date instanceof Date ? date : new Date(date * 1000);
+}
+
 export default {
     name: 'datetimepicker',
     inheritAttrs: false,
@@ -66,19 +70,19 @@ export default {
             }
         };
         if (this.mindate) {
-            params.minDate = new Date(this.mindate * 1000)
+            params.minDate = convertDate(this.mindate);
         }
         if (this.maxdate) {
-            params.maxDate = new Date(this.maxdate * 1000)
+            params.maxDate = convertDate(this.maxdate);
         }
         $(this.$refs.visibleInput).datetimepicker(params);
     },
     watch: {
         mindat (new_data, old_data) {
-            $(this.$refs.visibleInput).datetimepicker('option', 'minDate', new Date(new_data * 1000));
+            $(this.$refs.visibleInput).datetimepicker('option', 'minDate', convertDate(new_data));
         },
         maxdate (new_data, old_data) {
-            $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', new Date(new_data * 1000));
+            $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', convertDate(new_data));
         }
     }
 }
diff --git a/resources/vue/components/StudipActionMenu.vue b/resources/vue/components/StudipActionMenu.vue
index 014a1cacefc..f03becb5502 100644
--- a/resources/vue/components/StudipActionMenu.vue
+++ b/resources/vue/components/StudipActionMenu.vue
@@ -17,13 +17,28 @@
                         <span v-else class="action-menu-no-icon"></span>
                         {{ item.label }}
                     </a>
-                    <label v-else-if="item.icon" class="undecorated" v-bind="linkAttributes(item)" v-on="linkEvents(item)">
-                        <studip-icon :shape="item.icon.shape" :role="item.icon.role" :name="item.name" :title="item.label" v-bind="item.attributes ?? {}"></studip-icon>
+                    <label
+                        v-else-if="item.icon"
+                        class="undecorated"
+                        v-bind="linkAttributes(item)"
+                        v-on="linkEvents(item)"
+                    >
+                        <StudipIcon
+                            :shape="item.icon.shape"
+                            :role="item.icon.role"
+                            :name="item.name"
+                            :title="item.label"
+                            v-bind="item.attributes ?? {}"
+                        ></StudipIcon>
                         {{ item.label }}
                     </label>
                     <template v-else>
                         <span class="action-menu-no-icon"></span>
-                        <button :name="item.name" v-bind="Object.assign(item.attributes ?? {}, linkAttributes(item))" v-on="linkEvents(item)">
+                        <button
+                            :name="item.name"
+                            v-bind="Object.assign(item.attributes ?? {}, linkAttributes(item))"
+                            v-on="linkEvents(item)"
+                        >
                             {{ item.label }}
                         </button>
                     </template>
@@ -40,8 +55,11 @@
 </template>
 
 <script>
+import StudipIcon from './StudipIcon.vue';
+
 export default {
     name: 'studip-action-menu',
+    components: { StudipIcon },
     props: {
         items: Array,
         collapseAt: {
@@ -49,16 +67,16 @@ export default {
         },
         context: {
             type: String,
-            default: ''
-        }
+            default: '',
+        },
     },
-    data () {
+    data() {
         return {
-            open: false
+            open: false,
         };
     },
     methods: {
-        linkAttributes (item) {
+        linkAttributes(item) {
             let attributes = item.attributes;
             attributes.class = item.classes;
 
@@ -72,7 +90,7 @@ export default {
 
             return attributes;
         },
-        linkEvents (item) {
+        linkEvents(item) {
             let events = {};
             if (item.emit) {
                 events.click = (e) => {
@@ -83,26 +101,28 @@ export default {
             }
             return events;
         },
-        close () {
-            STUDIP.ActionMenu.closeAll();
-        }
+        close() {
+            window.STUDIP.ActionMenu?.closeAll();
+        },
     },
     computed: {
-        navigationItems () {
+        navigationItems() {
             return this.items.map((item) => {
                 let classes = item.classes ?? '';
                 if (item.disabled) {
-                    classes += " action-menu-item-disabled";
+                    classes += ' action-menu-item-disabled';
                 }
                 return {
                     label: item.label,
                     url: item.url || '#',
                     emit: item.emit || false,
                     emitArguments: item.emitArguments || [],
-                    icon: item.icon ? {
-                        shape: item.icon,
-                        role: item.disabled ? 'inactive' : 'clickable'
-                    } : false,
+                    icon: item.icon
+                        ? {
+                              shape: item.icon,
+                              role: item.disabled ? 'inactive' : 'clickable',
+                          }
+                        : false,
                     type: item.type || 'link',
                     name: item.name ?? null,
                     classes: classes.trim(),
@@ -122,9 +142,11 @@ export default {
             }
             return Number.parseInt(collapseAt) <= this.items.filter((item) => item.type !== 'separator').length;
         },
-        title () {
-            return this.context ? this.$gettextInterpolate(this.$gettext('Aktionsmenü für %{context}'), {context: this.context}) : this.$gettext('Aktionsmenü');
-        }
-    }
-}
+        title() {
+            return this.context
+                ? this.$gettextInterpolate(this.$gettext('Aktionsmenü für %{context}'), { context: this.context })
+                : this.$gettext('Aktionsmenü');
+        },
+    },
+};
 </script>
diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue
new file mode 100644
index 00000000000..e91f69d39c4
--- /dev/null
+++ b/resources/vue/components/StudipArticle.vue
@@ -0,0 +1,63 @@
+<template>
+    <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs">
+        <header>
+            <h1 @click="doToggle">
+                <template v-if="collapsable">
+                    <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" />
+                    <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else />
+                </template>
+                <slot name="title" v-bind="{ isOpen: collapsed }"></slot>
+            </h1>
+            <slot v-if="$slots.titleplus" name="titleplus"></slot>
+        </header>
+        <section v-if="!collapsed">
+            <slot name="body"></slot>
+        </section>
+        <footer v-if="$slots.footer">
+            <slot name="footer"></slot>
+        </footer>
+    </article>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default Vue.extend({
+    props: {
+        collapsable: {
+            type: Boolean,
+            default: false,
+        },
+        closed: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    components: { StudipIcon },
+    data() {
+        return { collapsed: this.closed };
+    },
+    methods: {
+        doToggle() {
+            if (this.collapsable) {
+                this.collapsed = !this.collapsed;
+            }
+        },
+    },
+});
+</script>
+<style scoped>
+article.studip.collapsable.collapsed {
+    padding-block-end: 0;
+}
+article.studip.collapsable.collapsed > header {
+    margin-block-end: 0;
+}
+article.studip.collapsable > header > h1 {
+    cursor: pointer;
+}
+
+.studip-articles--icon {
+}
+</style>
diff --git a/resources/vue/components/StudipContentBox.vue b/resources/vue/components/StudipContentBox.vue
new file mode 100644
index 00000000000..ab5bf9584c2
--- /dev/null
+++ b/resources/vue/components/StudipContentBox.vue
@@ -0,0 +1,46 @@
+<template>
+    <section class="contentbox">
+        <header v-if="title || $slots.header">
+            <slot name="header">
+                <h1>
+                    <StudipIcon v-if="icon" :shape="icon" />
+                    {{ title }}
+                </h1>
+            </slot>
+            <slot name="header-nav">
+                <nav v-if="items">
+                    <StudipActionMenu :items="items" />
+                </nav>
+            </slot>
+        </header>
+
+        <slot></slot>
+
+        <footer>
+            <slot name="footer"></slot>
+        </footer>
+    </section>
+</template>
+
+<script>
+import StudipActionMenu from './StudipActionMenu.vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default {
+    components: { StudipActionMenu, StudipIcon },
+    props: {
+        icon: {
+            type: String,
+            required: false,
+        },
+        items: {
+            type: Array,
+            required: false,
+        },
+        title: {
+            type: String,
+            required: true,
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/StudipDate.vue b/resources/vue/components/StudipDate.vue
new file mode 100644
index 00000000000..2e30b9d250a
--- /dev/null
+++ b/resources/vue/components/StudipDate.vue
@@ -0,0 +1,27 @@
+<template>
+    <time :datetime="date.toISOString()">{{ formatted }}</time>
+</template>
+
+<script>
+function formatDate(date) {
+    return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear();
+}
+
+function pad(what) {
+    return what.toString().padStart(2, '0');
+}
+
+export default {
+    props: {
+        date: {
+            type: Date,
+            required: true,
+        },
+    },
+    computed: {
+        formatted() {
+            return formatDate(this.date);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue
new file mode 100644
index 00000000000..eecd4a7b228
--- /dev/null
+++ b/resources/vue/components/StudipUserAvatar.vue
@@ -0,0 +1,32 @@
+<template>
+    <div class="studip-user-avatar">
+        <span>
+            <img :src="avatarUrl" />
+        </span>
+        <span>{{ formattedName }}</span>
+    </div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+    props: {
+        avatarUrl: {
+            type: String,
+            required: true,
+        },
+        formattedName: {
+            type: String,
+            required: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+.studip-user-avatar {
+    align-items: center;
+    display: flex;
+    gap: 0.25rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue
deleted file mode 100644
index ff59669729a..00000000000
--- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue
+++ /dev/null
@@ -1,481 +0,0 @@
-<template>
-    <div class="cw-dashboard-students-wrapper">
-        <table v-if="tasks.length > 0" class="default">
-            <colgroup>
-                <col />
-            </colgroup>
-            <thead>
-                <tr class="sortable">
-                    <th>{{ $gettext('Status') }}</th>
-                    <th :class="getSortClass('task-title')" @click="sort('task-title')">
-                        {{ $gettext('Aufgabentitel') }}
-                    </th>
-                    <th :class="getSortClass('solver-name')" @click="sort('solver-name')">
-                        {{ $gettext('Teilnehmende/Gruppen') }}
-                    </th>
-                    <th class="responsive-hidden" :class="getSortClass('page-title')" @click="sort('page-title')">
-                        {{ $gettext('Seite') }}
-                    </th>
-                    <th :class="getSortClass('progress')" @click="sort('progress')">
-                        {{ $gettext('bearbeitet') }}
-                    </th>
-                    <th :class="getSortClass('submission-date')" @click="sort('submission-date')">
-                        {{ $gettext('Abgabefrist') }}
-                    </th>
-                    <th>{{ $gettext('Abgabe') }}</th>
-                    <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th>
-                    <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr v-for="{ task, taskGroup, status, element, user, group, feedback } in tasks" :key="task.id">
-                    <td>
-                        <studip-icon
-                            v-if="status.shape !== undefined"
-                            :shape="status.shape"
-                            :role="status.role"
-                            :title="status.description"
-                            aria-hidden="true"
-                        />
-                        <span class="sr-only">{{ status.description }}</span>
-                    </td>
-                    <td>
-                        {{ taskGroup && taskGroup.attributes.title }}
-                    </td>
-                    <td>
-                        <span v-if="user">
-                            <studip-icon 
-                                shape="person2"
-                                role="info"
-                                aria-hidden="true"
-                                :title="$gettext('Teilnehmende Person')" 
-                            />
-                            <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span>
-                            {{ user.attributes['formatted-name'] }}
-
-                        </span>
-                        <span v-if="group">
-                            <studip-icon
-                                shape="group2"
-                                role="info"
-                                aria-hidden="true"
-                                :title="$gettext('Gruppe')"
-                            />
-                            <span class="sr-only">{{ $gettext('Gruppe') }}</span>
-                            {{ group.attributes['name'] }}
-
-                        </span>
-                    </td>
-                    <td class="responsive-hidden">
-                        <a v-if="task.attributes.submitted" :href="getLinkToElement(element)">
-                            {{ element.attributes.title }}
-                        </a>
-                        <span v-else>{{ element.attributes.title }}</span>
-                    </td>
-                    <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td>
-                    <td>{{ getReadableDate(task.attributes['submission-date']) }}</td>
-                    <td>
-                        <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" />
-                    </td>
-                    <td class="responsive-hidden">
-                        <button
-                            v-show="task.attributes.renewal === 'pending'"
-                            class="button"
-                            @click="solveRenewalRequest(task)"
-                        >
-                            {{ $gettext('Anfrage bearbeiten') }}
-                        </button>
-                        <span v-show="task.attributes.renewal === 'declined'">
-                            <studip-icon shape="decline" role="status-red" />
-                            {{ $gettext('Anfrage abgelehnt') }}
-                        </span>
-                        <span v-show="task.attributes.renewal === 'granted'">
-                            {{ $gettext('verlängert bis') }}:
-                            {{ getReadableDate(task.attributes['renewal-date']) }}
-                        </span>
-                        <studip-icon
-                            v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'"
-                            :title="$gettext('Anfrage bearbeiten')"
-                            class="edit"
-                            shape="edit"
-                            role="clickable"
-                            @click="solveRenewalRequest(task)"
-                        />
-                    </td>
-                    <td class="responsive-hidden">
-                        <span
-                            v-if="feedback"
-                            :title="
-                                $gettext('Feedback geschrieben am:') +
-                                ' ' +
-                                getReadableDate(feedback.attributes['chdate'])
-                            "
-                        >
-                            <studip-icon shape="accept" role="status-green" />
-                            {{ $gettext('Feedback gegeben') }}
-                            <studip-icon
-                                :title="$gettext('Feedback bearbeiten')"
-                                class="edit"
-                                shape="edit"
-                                role="clickable"
-                                @click="editFeedback(feedback)"
-                            />
-                        </span>
-
-                        <button
-                            v-show="!feedback && task.attributes.submitted"
-                            class="button"
-                            @click="addFeedback(task)"
-                        >
-                            {{ $gettext('Feedback geben') }}
-                        </button>
-                    </td>
-                </tr>
-            </tbody>
-        </table>
-        <div v-else>
-            <courseware-companion-box 
-                mood="pointing"
-                :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')"
-            >
-            </courseware-companion-box>
-        </div>
-        <studip-dialog
-            v-if="showRenewalDialog"
-            :title="text.renewalDialog.title"
-            :confirmText="text.renewalDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.renewalDialog.close"
-            closeClass="cancel"
-            height="350"
-            @close="
-                showRenewalDialog = false;
-                currentDialogTask = {};
-            "
-            @confirm="updateRenewal"
-        >
-            <template v-slot:dialogContent>
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Fristverlängerung') }}
-                        <select v-model="currentDialogTask.attributes.renewal">
-                            <option value="declined">
-                                {{ $gettext('ablehnen') }}
-                            </option>
-                            <option value="granted">
-                                {{ $gettext('gewähren') }}
-                            </option>
-                        </select>
-                    </label>
-                    <label v-if="currentDialogTask.attributes.renewal === 'granted'">
-                        {{ $gettext('neue Frist') }}
-                        <courseware-date-input v-model="currentDialogTask.attributes['renewal-date']" class="size-l" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <studip-dialog
-            v-if="showEditFeedbackDialog"
-            :title="text.editFeedbackDialog.title"
-            :confirmText="text.editFeedbackDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.editFeedbackDialog.close"
-            closeClass="cancel"
-            height="420"
-            @close="
-                showEditFeedbackDialog = false;
-                currentDialogFeedback = {};
-            "
-            @confirm="updateFeedback"
-        >
-            <template v-slot:dialogContent>
-                <courseware-companion-box
-                    v-if="currentDialogFeedback.attributes.content === ''"
-                    mood="pointing"
-                    :msgCompanion="
-                        $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!')
-                    "
-                />
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Feedback') }}
-                        <textarea v-model="currentDialogFeedback.attributes.content" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <studip-dialog
-            v-if="showAddFeedbackDialog"
-            :title="text.addFeedbackDialog.title"
-            :confirmText="text.addFeedbackDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.addFeedbackDialog.close"
-            closeClass="cancel"
-            @close="
-                showAddFeedbackDialog = false;
-                currentDialogFeedback = {};
-            "
-            @confirm="createFeedback"
-        >
-            <template v-slot:dialogContent>
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Feedback') }}
-                        <textarea v-model="currentDialogFeedback.attributes.content" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <courseware-tasks-dialog-distribute v-if="showTasksDistributeDialog" @newtask="reloadTasks"/>
-    </div>
-</template>
-
-<script>
-import StudipIcon from './../StudipIcon.vue';
-import StudipDialog from './../StudipDialog.vue';
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import CoursewareDateInput from './layouts/CoursewareDateInput.vue';
-import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
-import taskHelperMixin from '../../mixins/courseware/task-helper.js';
-import { mapActions, mapGetters } from 'vuex';
-
-
-export default {
-    name: 'courseware-dashboard-students',
-    mixins: [taskHelperMixin],
-    components: {
-        CoursewareCompanionBox,
-        CoursewareDateInput,
-        StudipIcon,
-        StudipDialog,
-        CoursewareTasksDialogDistribute,
-    },
-    data() {
-        return {
-            showRenewalDialog: false,
-            showAddFeedbackDialog: false,
-            showEditFeedbackDialog: false,
-            currentDialogTask: {},
-            currentDialogFeedback: {},
-            text: {
-                renewalDialog: {
-                    title: this.$gettext('Verlängerungsanfrage bearbeiten'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-                editFeedbackDialog: {
-                    title: this.$gettext('Feedback zur Aufgabe ändern'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-                addFeedbackDialog: {
-                    title: this.$gettext('Feedback zur Aufgabe geben'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-            },
-            sortBy: 'task-title',
-            sortASC: true,
-        };
-    },
-    computed: {
-        ...mapGetters({
-            context: 'context',
-            allTasks: 'courseware-tasks/all',
-            userById: 'users/byId',
-            statusGroupById: 'status-groups/byId',
-            getElementById: 'courseware-structural-elements/byId',
-            getFeedbackById: 'courseware-task-feedback/byId',
-            relatedTaskGroups: 'courseware-task-groups/related',
-            showTasksDistributeDialog: 'showTasksDistributeDialog'
-        }),
-        tasks() {
-            const tasks = this.allTasks.map((task) => {
-                const result = {
-                    task,
-                    taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }),
-                    status: this.getStatus(task),
-                    element: this.getElementById({ id: task.relationships['structural-element'].data.id }),
-                    user: null,
-                    group: null,
-                    feedback: null,
-                    solverName: null
-                };
-                let solver = task.relationships.solver.data;
-                if (solver.type === 'users') {
-                    result.user = this.userById({ id: solver.id });
-                    result.solverName = result.user.attributes['formatted-name'];
-                }
-                if (solver.type === 'status-groups') {
-                    result.group = this.statusGroupById({ id: solver.id });
-                    result.solverName = result.group.attributes['name'];
-                }
-
-                const feedbackId = task.relationships['task-feedback'].data?.id;
-                if (feedbackId) {
-                    result.feedback = this.getFeedbackById({ id: feedbackId });
-                }
-
-                return result;
-            });
-
-            return this.sortTasks(tasks);
-        },
-        managerUrl() {
-            return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', {cid: this.context.id});
-        }
-    },
-    methods: {
-        ...mapActions({
-            updateTask: 'updateTask',
-            createTaskFeedback: 'createTaskFeedback',
-            updateTaskFeedback: 'updateTaskFeedback',
-            deleteTaskFeedback: 'deleteTaskFeedback',
-            loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure',
-            copyStructuralElement: 'copyStructuralElement',
-            companionSuccess: 'companionSuccess',
-            companionError: 'companionError',
-            loadAllTasks: 'courseware-tasks/loadAll'
-        }),
-        addFeedback(task) {
-            this.currentDialogFeedback.attributes = {};
-            this.currentDialogFeedback.attributes.content = '';
-            this.currentDialogFeedback.relationships = {};
-            this.currentDialogFeedback.relationships.task = {};
-            this.currentDialogFeedback.relationships.task.data = {};
-            this.currentDialogFeedback.relationships.task.data.id = task.id;
-            this.currentDialogFeedback.relationships.task.data.type = task.type;
-            this.showAddFeedbackDialog = true;
-        },
-        createFeedback() {
-            if (this.currentDialogFeedback.attributes.content === '') {
-                this.companionError({
-                    info: this.$gettext('Bitte schreiben Sie ein Feedback.'),
-                });
-                return false;
-            }
-            this.showAddFeedbackDialog = false;
-            this.createTaskFeedback({
-                taskFeedback: this.currentDialogFeedback,
-            });
-            this.currentDialogFeedback = {};
-        },
-        editFeedback(feedback) {
-            this.currentDialogFeedback = _.cloneDeep(feedback);
-            this.showEditFeedbackDialog = true;
-        },
-        async updateFeedback() {
-            this.showEditFeedbackDialog = false;
-            let attributes = {};
-            attributes.content = this.currentDialogFeedback.attributes.content;
-            if (attributes.content === '') {
-                await this.deleteTaskFeedback({
-                    taskFeedbackId: this.currentDialogFeedback.id,
-                });
-                this.companionSuccess({
-                    info: this.$gettext('Feedback wurde gelöscht.'),
-                });
-            } else {
-                await this.updateTaskFeedback({
-                    attributes: attributes,
-                    taskFeedbackId: this.currentDialogFeedback.id,
-                });
-                this.companionSuccess({
-                    info: this.$gettext('Feedback wurde gespeichert.'),
-                });
-            }
-
-            this.currentDialogFeedback = {};
-        },
-        solveRenewalRequest(task) {
-            this.currentDialogTask = _.cloneDeep(task);
-            this.currentDialogTask.attributes['renewal-date'] = new Date().toISOString();
-            this.showRenewalDialog = true;
-        },
-        updateRenewal() {
-            this.showRenewalDialog = false;
-            let attributes = {};
-            attributes.renewal = this.currentDialogTask.attributes.renewal;
-            if (attributes.renewal === 'granted') {
-                attributes['renewal-date'] = new Date(this.currentDialogTask.attributes['renewal-date'] || Date.now()).toISOString();
-            }
-
-            this.updateTask({
-                attributes: attributes,
-                taskId: this.currentDialogTask.id,
-            });
-            this.currentDialogTask = {};
-        },
-        reloadTasks() {
-            this.loadAllTasks({ 
-                options: {
-                    'filter[cid]': this.context.id,
-                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer'
-                }
-            });
-        },
-        getSortClass(col) {
-            if (col === this.sortBy) {
-                return this.sortASC ? 'sortasc' : 'sortdesc';
-            }
-        },
-        sort(sortBy) {
-            if (this.sortBy === sortBy) {
-                this.sortASC = !this.sortASC;
-            } else {
-                this.sortBy = sortBy;
-            }
-        },
-        sortTasks(tasks) {
-            switch (this.sortBy) {
-                case 'task-title':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.taskGroup.attributes.title < b.taskGroup.attributes.title ? -1 : 1;
-                        } else {
-                            return a.taskGroup.attributes.title > b.taskGroup.attributes.title ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'solver-name':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.solverName < b.solverName ? -1 : 1;
-                        } else {
-                            return a.solverName > b.solverName ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'page-title':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.element.attributes.title < b.element.attributes.title ? -1 : 1;
-                        } else {
-                            return a.element.attributes.title > b.element.attributes.title ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'progress':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.task.attributes.progress < b.task.attributes.progress ? -1 : 1;
-                        } else {
-                            return a.task.attributes.progress > b.task.attributes.progress ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'submission-date':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return new Date(a.task.attributes['submission-date']) - new Date(b.task.attributes['submission-date']);
-                        } else {
-                            return new Date(b.task.attributes['submission-date']) - new Date(a.task.attributes['submission-date']);
-                        }
-                    });
-                    break;
-            }
-            return tasks;
-        },
-    },
-};
-</script>
diff --git a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
index 7415478383d..d81aa2952d3 100644
--- a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
+++ b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
@@ -2,11 +2,12 @@
     <div class="cw-collapsible" :class="{ 'cw-collapsible-open': isOpen }">
         <a href="#" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen">
             <header :class="{ 'cw-collapsible-open': isOpen }" class="cw-collapsible-title">
-                <studip-icon v-if="icon" :shape="icon" /> {{ title }}
+                <studip-icon v-if="icon" :shape="icon" />
+                <slot name="title" :is-open="isOpen">{{ title }}</slot>
             </header>
         </a>
         <div class="cw-collapsible-content" :class="{ 'cw-collapsible-content-open': isOpen }">
-            <slot></slot>
+            <slot :isOpen="isOpen"></slot>
         </div>
     </div>
 </template>
@@ -54,4 +55,4 @@ export default {
         }
     }
 };
-</script>
\ No newline at end of file
+</script>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
index dba1917eea9..1244134fe3b 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
@@ -34,8 +34,10 @@
                                 :title="structuralElement.attributes.title"
                             >
                                 <span>{{ structuralElement.attributes.title || "–" }}</span>
-                                <span v-if="isTask">[ {{ solverName }} ]</span>
-                                <template v-if="!userIsTeacher && inCourse">
+                                <span v-if="task">
+                                    [ {{ (userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ]
+                                </span>
+                                <template v-if="inCourse && !(userIsTeacher || userIsReviewer)">
                                     <studip-icon
                                         v-if="complete"
                                         shape="accept"
@@ -102,6 +104,18 @@
                                 </button>
                             </template>
                         </courseware-companion-box>
+                        <courseware-companion-box
+                            v-for="peerReview in peerReviews"
+                            :key="peerReview.id"
+                            mood="pointing"
+                            :msgCompanion="$gettext('Sie begutachten diese Seite im Rahmen eines Peer-Reviews.')"
+                            >
+                            <template #companionActions>
+                                <button class="button" @click="openPeerReviewForm(peerReview)">
+                                    {{ $gettext('Gutachten öffnen') }}
+                                </button>
+                            </template>
+                        </courseware-companion-box>
                         <courseware-empty-element-box
                             v-if="showEmptyElementBox"
                             :canEdit="canEdit"
@@ -630,6 +644,11 @@
                 <courseware-structural-element-dialog-import v-if="showImportDialog"/>
                 <courseware-structural-element-dialog-copy v-if="showCopyDialog" />
                 <courseware-structural-element-dialog-link v-if="showLinkDialog"/>
+                <PeerReviewAssessmentDialog
+                    v-model="showPeerReviewForm"
+                    v-if="selectedPeerReview"
+                    :review="selectedPeerReview"
+                    />
             </div>
             <div v-else>
                 <courseware-companion-box
@@ -659,6 +678,7 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement
 import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue';
 import CoursewareContentPermissions from '../CoursewareContentPermissions.vue';
 import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue';
+import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue';
 import CoursewareExport from '@/vue/mixins/courseware/export.js';
 import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js';
 import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -681,6 +701,9 @@ export default {
         CoursewareContentPermissions,
         CoursewareWelcomeScreen,
         CoursewareDateInput,
+        FocusTrap,
+        IsoDate,
+        PeerReviewAssessmentDialog,
         StockImageSelector,
         StudipDialog,
         draggable,
@@ -754,6 +777,8 @@ export default {
             uploadImageURL: null,
             showStockImageSelector: false,
             selectedStockImage: null,
+            showPeerReviewForm: false,
+            selectedPeerReview: null,
         };
     },
 
@@ -764,6 +789,8 @@ export default {
             consumeMode: 'consumeMode',
             containerById: 'courseware-containers/byId',
             relatedContainers: 'courseware-containers/related',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
             relatedStructuralElements: 'courseware-structural-elements/related',
             relatedTaskGroups: 'courseware-task-groups/related',
             relatedUsers: 'users/related',
@@ -881,7 +908,7 @@ export default {
                     return true;
                 }
             }
-            
+
 
             return false;
         },
@@ -1229,7 +1256,28 @@ export default {
                 'dispatch.php/course/courseware/courseware/' + this.context.unit,
                 {cid: this.context.id}
             );
-        }
+        },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
+        peerReviews() {
+            if (this.isTask && this.userIsReviewer) {
+                return this.relatedPeerReviews({
+                    parent: { id: this.task.id, type: this.task.type },
+                    relationship: 'peer-reviews',
+                });
+            }
+            return [];
+        },
+        isPeerReviewAnonymous() {
+            return this.peerReviews.every(({ id, type }) => {
+                const process = this.relatedPeerReviewProcesses({
+                    parent: { id, type },
+                    relationship: 'process',
+                });
+                return process.attributes.configuration.anonymous;
+            });
+        },
     },
 
     methods: {
@@ -1694,6 +1742,10 @@ export default {
             this.showStockImageSelector = false;
             this.deletingPreviewImage = false;
         },
+        openPeerReviewForm(peerReview) {
+            this.showPeerReviewForm = true;
+            this.selectedPeerReview = peerReview;
+        },
     },
     created() {
         this.pluginManager.registerComponentsLocally(this);
diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
index a4bfa35e48c..49c6cbd1642 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
@@ -32,7 +32,7 @@
                     <studip-icon shape="edit" />
                 </button>
 
-                <span v-if="task">| {{ solverName }}</span>
+                <span v-if="task">| {{ userIsReviewer ? $gettext("anonym") : solverName }}</span>
                 <span
                     v-if="hasReleaseOrWithdrawDate"
                     class="cw-tree-item-flag-date"
@@ -48,7 +48,7 @@
                     class="cw-tree-item-flag-cant-read"
                     :title="$gettext('Diese Seite kann von Teilnehmenden nicht gesehen werden')"
                 ></span>
-                <template v-if="!userIsTeacher && inCourse">
+                <template v-if="!(userIsTeacher || userIsReviewer)  && inCourse">
                     <span
                         v-if="complete"
                         class="cw-tree-item-sequential cw-tree-item-sequential-complete"
@@ -328,6 +328,9 @@ export default {
         complete() {
             return this.itemProgress === 100;
         },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
     },
     methods: {
         ...mapActions({
diff --git a/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
new file mode 100644
index 00000000000..2d7c28104e9
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
@@ -0,0 +1,48 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Feedback zur Aufgabe geben')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="$emit('close')"
+        @confirm="create"
+    >
+        <template #dialogContent>
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Feedback') }}
+                    <textarea v-model="localContent" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+export default {
+    props: ['content'],
+    data: () => ({
+        localContent: '',
+    }),
+    methods: {
+        resetLocalVars() {
+            this.localContent = this.content;
+        },
+        create() {
+            this.$emit('create', { content: this.localContent });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        content(newValue) {
+            if (newValue !== this.localContent) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
new file mode 100644
index 00000000000..1bdbf3ce0e1
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -0,0 +1,91 @@
+<template>
+    <div class="cw-dashboard-students-wrapper">
+        <table class="default" v-if="taskGroups.length">
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Status') }}</th>
+                    <th>{{ $gettext('Titel') }}</th>
+                    <th>{{ $gettext('Bearbeitungszeit') }}</th>
+                </tr>
+            </thead>
+            <tbody>
+            <tr v-for="(taskGroup, index) in taskGroups">
+                <td>
+                    <StudipIcon
+                        :shape="status(taskGroup).shape"
+                        :role="status(taskGroup).role"
+                        :title="status(taskGroup).description"
+                        aria-hidden="true"
+                        />
+                    <span class="sr-only">{{ status(taskGroup).description }}</span>
+                </td>
+                <td>
+                    <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">{{ taskGroup.attributes.title }}</router-link>
+                </td>
+                <td>
+                    <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate :date="new Date(taskGroup.attributes['end-date'])" />
+                </td>
+            </tr>
+            </tbody>
+        </table>
+
+        <CompanionBox v-else-if="!tasksLoading"
+            :msgCompanion="$gettext('Es wurden noch keine Aufgaben verteilt.')">
+            <template #companionActions>
+                <button @click="setShowTasksDistributeDialog(true)" type="button" class="button">
+                    {{ $gettext("Aufgabe verteilen") }}
+                </button>
+            </template>
+        </CompanionBox>
+
+        <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" />
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import StudipDate from '../../StudipDate.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import { getStatus } from './task-groups-helper.js';
+
+export default {
+    name: 'courseware-dashboard-students',
+    components: {
+        CompanionBox,
+        CoursewareTasksDialogDistribute,
+        StudipDate,
+        StudipIcon,
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            showTasksDistributeDialog: 'tasks/showTasksDistributeDialog',
+            taskGroupsByCid: 'tasks/taskGroupsByCid',
+            tasksLoading: 'courseware-tasks/isLoading',
+        }),
+        taskGroups() {
+            return _.sortBy(this.taskGroupsByCid(this.context.id), [
+                (taskGroup) => -new Date(taskGroup.attributes['end-date']),
+            ]);
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadAllTasks: 'courseware-tasks/loadAll',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
+        }),
+        status: getStatus,
+        reloadTasks() {
+            this.loadAllTasks({
+                options: {
+                    'filter[cid]': this.context.id,
+                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                },
+            });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
similarity index 97%
rename from resources/vue/components/courseware/CoursewareDashboardTasks.vue
rename to resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
index caaea0bb1b6..93536af95e3 100644
--- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
@@ -102,11 +102,11 @@
     </div>
 </template>
 <script>
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import StudipIcon from '../StudipIcon.vue';
-import StudipActionMenu from '../StudipActionMenu.vue';
-import StudipDialog from '../StudipDialog.vue';
-import taskHelperMixin from '../../mixins/courseware/task-helper.js';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDialog from '../../StudipDialog.vue';
+import taskHelperMixin from '../../../mixins/courseware/task-helper.js';
 import { mapActions, mapGetters } from 'vuex';
 
 export default {
diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
similarity index 93%
rename from resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
rename to resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
index 79c8cacac60..27e99a6d3b3 100644
--- a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
@@ -27,10 +27,10 @@
                             :key="'label-' + unit.id"
                             :for="'cw-task-dist-source-unit' + unit.id"
                         >
-                            <div class="icon"><studip-icon shape="courseware" size="32" /></div>
+                            <div class="icon"><studip-icon shape="courseware" :size="32" /></div>
                             <div class="text">{{ unit.element.attributes.title }}</div>
-                            <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" />
-                            <studip-icon shape="check-circle" size="24" class="check" />
+                            <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" />
+                            <studip-icon shape="check-circle" :size="24" class="check" />
                         </label>
                     </template>
                 </fieldset>
@@ -62,10 +62,15 @@
                     <span aria-hidden="true" class="wizard-required">*</span>
                     <input type="text" v-model="taskTitle" required />
                 </label>
+                <label>
+                    <span>{{ $gettext('Startdatum') }}</span>
+                    <span aria-hidden="true" class="wizard-required">*</span>
+                    <input type="date" v-model="startDate" />
+                </label>
                 <label>
                     <span>{{ $gettext('Abgabefrist') }}</span>
                     <span aria-hidden="true" class="wizard-required">*</span>
-                    <input type="date" v-model="submissionDate" />
+                    <input type="date" v-model="endDate" :min="startDate" />
                 </label>
                 <label>
                     {{ $gettext('Inhalte ergänzen') }}
@@ -99,10 +104,10 @@
                             :key="'label-' + unit.id"
                             :for="'cw-task-dist-target-unit' + unit.id"
                         >
-                            <div class="icon"><studip-icon shape="courseware" size="32" /></div>
+                            <div class="icon"><studip-icon shape="courseware" :size="32" /></div>
                             <div class="text">{{ unit.element.attributes.title }}</div>
-                            <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" />
-                            <studip-icon shape="check-circle" size="24" class="check" />
+                            <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" />
+                            <studip-icon shape="check-circle" :size="24" class="check" />
                         </label>
                     </template>
                 </fieldset>
@@ -237,9 +242,9 @@
 </template>
 
 <script>
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import CoursewareStructuralElementSelector from './structural-element/CoursewareStructuralElementSelector.vue';
-import StudipWizardDialog from '../StudipWizardDialog.vue';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareStructuralElementSelector from '../structural-element/CoursewareStructuralElementSelector.vue';
+import StudipWizardDialog from '../../StudipWizardDialog.vue';
 
 import { mapActions, mapGetters } from 'vuex';
 
@@ -316,7 +321,8 @@ export default {
             ],
             selectedSourceUnit: null,
             taskTitle: '',
-            submissionDate: '',
+            startDate: new Date().toJSON().substr(0, 10),
+            endDate: '',
             solverMayAddBlocks: true,
             selectedTask: null,
             selectedTargetUnit: null,
@@ -488,7 +494,7 @@ export default {
     },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
             loadCourseUnits: 'loadCourseUnits',
             loadUserUnits: 'loadUserUnits',
             loadStructuralElement: 'courseware-structural-elements/loadById',
@@ -522,10 +528,21 @@ export default {
                 return;
             }
             this.distributing = true;
+            const startDate = new Date(this.startDate);
+            startDate.setHours(0);
+            startDate.setMinutes(0);
+            startDate.setSeconds(0);
+            startDate.setMilliseconds(0);
+            const endDate = new Date(this.endDate);
+            endDate.setHours(23);
+            endDate.setMinutes(59);
+            endDate.setSeconds(59);
+            endDate.setMilliseconds(999);
             const taskGroup = {
                 attributes: {
                     title: this.taskTitle,
-                    'submission-date': new Date(this.submissionDate).toISOString(),
+                    'start-date': startDate.toISOString(),
+                    'end-date': endDate.toISOString(),
                     'solver-may-add-blocks': this.solverMayAddBlocks,
                 },
                 relationships: {
@@ -560,7 +577,7 @@ export default {
             this.$emit('newtask');
             this.distributing = false;
             this.setShowTasksDistributeDialog(false);
-            
+
         },
         validateSolvers() {
             if (
@@ -575,7 +592,7 @@ export default {
             return this.wizardSlots[5].valid;
         },
         validateTaskSettings() {
-            if (this.taskTitle !== '' && this.submissionDate !== '') {
+            if (this.taskTitle !== '' && this.endDate !== '') {
                 this.wizardSlots[2].valid = true;
             } else {
                 this.wizardSlots[2].valid = false;
@@ -651,7 +668,14 @@ export default {
         taskTitle() {
             this.validate();
         },
-        submissionDate() {
+        startDate() {
+            if (new Date(this.startDate) > new Date(this.endDate)) {
+                const endDate = new Date(this.startDate);
+                endDate.setDate(endDate.getDate() + 1);
+                this.endDate = endDate.toJSON().substr(0, 10);
+            }
+        },
+        endDate() {
             this.validate();
         },
         selectedAutors() {
diff --git a/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
new file mode 100644
index 00000000000..a07356dfc68
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
@@ -0,0 +1,60 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Feedback zur Aufgabe ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="$emit('close')"
+        @confirm="update"
+    >
+        <template #dialogContent>
+            <CompanionBox
+                v-if="localContent === ''"
+                mood="pointing"
+                :msgCompanion="
+                    $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!')
+                "
+            />
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Feedback') }}
+                    <textarea v-model="localContent" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+
+export default {
+    props: ['content'],
+    components: {
+        CompanionBox,
+    },
+    data: () => ({
+        localContent: '',
+    }),
+    methods: {
+        resetLocalVars() {
+            this.localContent = this.content;
+        },
+        update() {
+            this.$emit('update', { content: this.localContent });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        content(newValue) {
+            if (newValue !== this.localContent) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/TasksApp.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
similarity index 70%
rename from resources/vue/components/courseware/TasksApp.vue
rename to resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
index 8a406d80864..5701580d489 100644
--- a/resources/vue/components/courseware/TasksApp.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
@@ -1,21 +1,21 @@
 <template>
     <div class="cw-tasks-wrapper">
         <div class="cw-tasks-list">
-            <courseware-dashboard-students v-if="userIsTeacher" />
-            <courseware-dashboard-tasks v-else />
+            <CoursewareDashboardStudents v-if="userIsTeacher" />
+            <CoursewareDashboardTasks v-else />
         </div>
         <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
-            <courseware-tasks-action-widget />
+            <CoursewareTasksActionWidget />
         </MountingPortal>
         <courseware-companion-overlay />
     </div>
 </template>
 
 <script>
-import CoursewareTasksActionWidget from './widgets/CoursewareTasksActionWidget.vue';
+import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
 import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue';
 import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue';
-import CoursewareCompanionOverlay from './layouts/CoursewareCompanionOverlay.vue';
+import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue';
 import { mapGetters } from 'vuex';
 
 export default {
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
new file mode 100644
index 00000000000..77499aba5f5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -0,0 +1,238 @@
+<template>
+    <div class="cw-tasks-wrapper">
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <CoursewareTasksActionWidget :taskGroup="taskGroup" />
+        </MountingPortal>
+
+        <div v-if="taskGroup" class="cw-tasks-list">
+            <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
+                <template #buttons>
+                    <router-link :to="{ name: 'task-groups-index' }">
+                        <StudipIcon shape="category-task" :size="24" />
+                    </router-link>
+                </template>
+                <template #breadcrumbList>
+                    <li>
+                        <router-link :to="{ name: 'task-groups-index' }">
+                            {{ $gettext('Aufgaben') }}
+                        </router-link>
+                    </li>
+                    <li>{{ taskGroup.attributes['title'] }}</li>
+                </template>
+            </CoursewareRibbon>
+
+            <TaskGroup
+                :taskGroup="taskGroup"
+                :tasks="tasksByGroup[taskGroup.id]"
+                @add-feedback="onShowAddFeedback"
+                @add-peer-review-process="onShowPeerReviewProcessCreate"
+                @edit-feedback="onShowEditFeedback"
+                @solve-renewal="onShowSolveRenewal"
+            />
+        </div>
+        <CompanionBox
+            v-else-if="!tasksLoading"
+            :msgCompanion="$gettext('Diese Courseware-Aufgabe konnte nicht gefunden werden.')"
+        />
+
+        <AddFeedbackDialog
+            v-if="showAddFeedbackDialog"
+            :content="currentDialogFeedback.attributes.content"
+            @create="createFeedback"
+            @close="closeDialogs"
+        />
+
+        <EditFeedbackDialog
+            v-if="showEditFeedbackDialog"
+            :content="currentDialogFeedback.attributes.content"
+            @update="updateFeedback"
+            @close="closeDialogs"
+        />
+
+        <PeerReviewProcessCreateDialog
+            v-if="showPeerReviewProcessCreate"
+            :taskGroup="taskGroup"
+            @create="onCreatePeerReviewProcess"
+            @close="closeDialogs"
+        />
+
+        <RenewalDialog
+            v-if="renewalTask"
+            :renewalDate="renewalDate"
+            :renewalState="renewalTask.attributes.renewal"
+            @update="updateRenewal"
+            @close="closeDialogs"
+        />
+
+        <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="taskGroup" />
+        <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="taskGroup" />
+        <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" />
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import AddFeedbackDialog from './AddFeedbackDialog.vue';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
+import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
+import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import EditFeedbackDialog from './EditFeedbackDialog.vue';
+import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue';
+import RenewalDialog from './RenewalDialog.vue';
+import TaskGroup from './TaskGroup.vue';
+import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
+import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
+
+export default {
+    components: {
+        AddFeedbackDialog,
+        CompanionBox,
+        CoursewareRibbon,
+        CoursewareTasksActionWidget,
+        CoursewareTasksDialogDistribute,
+        EditFeedbackDialog,
+        PeerReviewProcessCreateDialog,
+        RenewalDialog,
+        TaskGroup,
+        TaskGroupsDeleteDialog,
+        TaskGroupsModifyDeadlineDialog,
+    },
+    props: ['id'],
+    data() {
+        return {
+            currentDialogFeedback: {},
+            renewalTask: null,
+            showAddFeedbackDialog: false,
+            showPeerReviewProcessCreate: null,
+            showEditFeedbackDialog: false,
+        };
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            getTaskGroup: 'courseware-task-groups/byId',
+            showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog',
+            showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog',
+            showTasksDistributeDialog: 'tasks/showTasksDistributeDialog',
+            tasksByCid: 'tasks/tasksByCid',
+            tasksLoading: 'courseware-tasks/isLoading',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        renewalDate() {
+            return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date();
+        },
+        taskGroup() {
+            return this.getTaskGroup({ id: this.id });
+        },
+        tasksByGroup() {
+            return this.tasksByCid(this.context.id).reduce((memo, task) => {
+                const key = task.relationships['task-group'].data.id;
+                (memo[key] || (memo[key] = [])).push(task);
+
+                return memo;
+            }, {});
+        },
+    },
+    methods: {
+        ...mapActions({
+            companionError: 'companionError',
+            companionSuccess: 'companionSuccess',
+            createPeerReviewProcess: 'tasks/createPeerReviewProcess',
+            createTaskFeedback: 'createTaskFeedback',
+            deleteTaskFeedback: 'deleteTaskFeedback',
+            loadAllTasks: 'courseware-tasks/loadAll',
+            loadTaskGroup: 'tasks/loadTaskGroup',
+            updateTask: 'updateTask',
+            updateTaskFeedback: 'updateTaskFeedback',
+        }),
+        closeDialogs() {
+            this.showAddFeedbackDialog = false;
+            this.showEditFeedbackDialog = false;
+            this.showPeerReviewProcessCreate = false;
+
+            this.currentDialogFeedback = {};
+            this.renewalTask = null;
+        },
+        createFeedback({ content }) {
+            if (content === '') {
+                this.companionError({
+                    info: this.$gettext('Bitte schreiben Sie ein Feedback.'),
+                });
+                return false;
+            }
+            this.currentDialogFeedback.attributes.content = content;
+            this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback });
+            this.closeDialogs();
+        },
+        onCreatePeerReviewProcess(options) {
+            this.createPeerReviewProcess({ taskGroup: this.taskGroup, options })
+                .then(() => this.loadTaskGroup(this.taskGroup))
+                .then(() => this.closeDialogs());
+        },
+        onShowAddFeedback(task) {
+            this.currentDialogFeedback = {
+                attributes: { content: '' },
+                relationships: {
+                    task: {
+                        data: {
+                            id: task.id,
+                            type: task.type,
+                        },
+                    },
+                },
+            };
+            this.showAddFeedbackDialog = true;
+        },
+        onShowEditFeedback(feedback) {
+            this.currentDialogFeedback = _.cloneDeep(feedback);
+            this.showEditFeedbackDialog = true;
+        },
+        onShowPeerReviewProcessCreate() {
+            this.showPeerReviewProcessCreate = true;
+        },
+        onShowSolveRenewal(task) {
+            this.renewalTask = _.cloneDeep(task);
+            this.renewalTask.attributes['renewal-date'] = new Date().toISOString();
+        },
+        reloadTasks() {
+            this.loadAllTasks({
+                options: {
+                    'filter[cid]': this.context.id,
+                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                },
+            });
+        },
+        updateRenewal({ state, date }) {
+            const attributes = { renewal: state };
+            if (date) {
+                attributes['renewal-date'] = date.toISOString();
+            }
+
+            this.updateTask({ attributes, taskId: this.renewalTask.id });
+            this.closeDialogs();
+        },
+        async updateFeedback({ content }) {
+            if (content === '') {
+                await this.deleteTaskFeedback({ taskFeedbackId: this.currentDialogFeedback.id });
+                this.companionSuccess({ info: this.$gettext('Feedback wurde gelöscht.') });
+            } else {
+                await this.updateTaskFeedback({
+                    attributes: { content },
+                    taskFeedbackId: this.currentDialogFeedback.id,
+                });
+                this.companionSuccess({
+                    info: this.$gettext('Feedback wurde gespeichert.'),
+                });
+            }
+            this.closeDialogs();
+        },
+    },
+};
+</script>
+
+<style scoped>
+.cw-tasks-wrapper >>> .cw-ribbon-nav {
+    justify-content: center;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue
new file mode 100644
index 00000000000..f08719e22d2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue
@@ -0,0 +1,79 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Verlängerungsanfrage bearbeiten')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="350"
+        @close="$emit('close')"
+        @confirm="updateRenewal"
+    >
+        <template #dialogContent>
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Fristverlängerung') }}
+                    <select v-model="state">
+                        <option value="declined">
+                            {{ $gettext('ablehnen') }}
+                        </option>
+                        <option value="granted">
+                            {{ $gettext('gewähren') }}
+                        </option>
+                    </select>
+                </label>
+                <label v-if="state === 'granted'">
+                    {{ $gettext('neue Frist') }}
+                    <DateInput v-model="date" class="size-l" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import DateInput from '../layouts/CoursewareDateInput.vue';
+export default {
+    props: ['renewalDate', 'renewalState'],
+    components: {
+        DateInput,
+    },
+    data: () => ({
+        date: null,
+        state: null,
+    }),
+    methods: {
+        resetLocalVars() {
+            this.date = this.renewalDate ?? null;
+            this.state = this.renewalState;
+        },
+        updateRenewal() {
+            const date = new Date(this.date);
+            date.setHours(23);
+            date.setMinutes(59);
+            date.setSeconds(59);
+            date.setMilliseconds(999);
+
+            this.$emit('update', {
+                state: this.state,
+                date: this.state === 'granted' ? date || Date.now() : null,
+            });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        renewalDate(newValue) {
+            if (newValue !== this.date) {
+                this.resetLocalVars();
+            }
+        },
+        renewalState(newValue) {
+            if (newValue !== this.state) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue
new file mode 100644
index 00000000000..36e0ffc8035
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroup.vue
@@ -0,0 +1,169 @@
+<template>
+    <div>
+        <CompanionBox :msgCompanion="statusMessage">
+            <template #companionActions>
+                <span>
+                    {{ $gettext('Bearbeitungszeit') }}
+                    <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                </span>
+            </template>
+        </CompanionBox>
+
+        <section v-if="tasks.length > 0">
+            <table class="default">
+                <caption>
+                    {{
+                        $gettext('Verteilte Aufgaben')
+                    }}
+                </caption>
+                <colgroup>
+                    <col />
+                </colgroup>
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Status') }}</th>
+                        <th>{{ $gettext('Teilnehmende/Gruppen') }}</th>
+                        <th class="responsive-hidden">{{ $gettext('Seite') }}</th>
+                        <th>{{ $gettext('bearbeitet') }}</th>
+                        <th>{{ $gettext('Abgabefrist') }}</th>
+                        <th>{{ $gettext('Abgabe') }}</th>
+                        <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th>
+                        <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <TaskItem
+                        v-for="task in tasks"
+                        :task="task"
+                        :taskGroup="taskGroup"
+                        :key="task.id"
+                        @add-feedback="(task) => $emit('add-feedback', task)"
+                        @edit-feedback="(feedback) => $emit('edit-feedback', feedback)"
+                        @solve-renewal="(task) => $emit('solve-renewal', task)"
+                    />
+                </tbody>
+            </table>
+
+            <table class="default">
+                <caption>
+                    {{
+                        $gettext('Peer-Review-Verfahren')
+                    }}
+                    <span class="actions" v-if="false && peerReviewActionMenuItems.length">
+                        <StudipActionMenu
+                            :collapseAt="false"
+                            :context="actionMenuContext"
+                            :items="peerReviewActionMenuItems"
+                            @add-peer-review-process="() => $emit('add-peer-review-process', this.taskGroup)"
+                        />
+                    </span>
+                </caption>
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Status') }}</th>
+                        <th>{{ $gettext('Bearbeitungszeit') }}</th>
+                        <th>{{ $gettext('Einstellungen') }}</th>
+                    </tr>
+                </thead>
+                <tbody v-if="hasPeerReviewProcesses">
+                    <ProcessListItem
+                        v-for="(process, index) in peerReviewProcesses"
+                        :key="process.id"
+                        :process="process"
+                    />
+                </tbody>
+                <tbody v-else>
+                    <tr class="nohover">
+                        <td colspan="3">
+                            <CompanionBox
+                                v-if="isAfter"
+                                :msgCompanion="
+                                    $gettext('Für diese Aufgabe wurde kein Peer-Review-Verfahren aktiviert.')
+                                "
+                            >
+                            </CompanionBox>
+                            <CompanionBox
+                                v-else
+                                mood="pointing"
+                                :msgCompanion="
+                                    $gettext('Für diese Aufgabe wurde noch kein Peer-Review-Verfahren aktiviert.')
+                                "
+                            >
+                                <template #companionActions>
+                                    <button class="button" @click="$emit('add-peer-review-process', taskGroup)">
+                                        {{ $gettext('Peer-Review-Verfahren aktivieren') }}
+                                    </button>
+                                </template>
+                            </CompanionBox>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </section>
+        <div v-else>
+            <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" />
+        </div>
+    </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDate from '../../StudipDate.vue';
+import TaskItem from './TaskGroupTaskItem.vue';
+import ProcessListItem from './TaskGroupPeerReviewProcessListItem.vue';
+import { getStatus } from './task-groups-helper.js';
+
+export default {
+    components: { CompanionBox, ProcessListItem, StudipActionMenu, StudipDate, TaskItem },
+    props: ['taskGroup', 'tasks'],
+    computed: {
+        ...mapGetters({
+            coursewareContext: 'context',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+        }),
+        actionMenuContext() {
+            return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), {
+                taskGroup: this.taskGroup.attributes.title,
+            });
+        },
+        canAddPeerReviewProcess() {
+            return this.startDate < new Date();
+        },
+        endDate() {
+            return new Date(this.taskGroup.attributes['end-date']);
+        },
+        hasPeerReviewProcesses() {
+            return !!this.peerReviewProcesses;
+        },
+        isAfter() {
+            return new Date() > this.endDate;
+        },
+        peerReviewActionMenuItems() {
+            return this.isAfter
+                ? []
+                : [
+                      {
+                          emit: 'add-peer-review-process',
+                          icon: 'add',
+                          id: 'add-peer-review-processes',
+                          label: this.$gettext('Peer-Review aktivieren'),
+                      },
+                  ];
+        },
+        peerReviewProcesses() {
+            return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' });
+        },
+        startDate() {
+            return new Date(this.taskGroup.attributes['start-date']);
+        },
+        status() {
+            return getStatus(this.taskGroup);
+        },
+        statusMessage() {
+            return this.status.description;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
new file mode 100644
index 00000000000..830a30fc40a
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
@@ -0,0 +1,68 @@
+<template>
+    <tr>
+        <td>
+            <StudipIcon
+                v-if="status.shape !== undefined"
+                :shape="status.shape"
+                :role="status.role"
+                :title="status.description"
+                aria-hidden="true"
+            />
+            <span class="sr-only">{{ status.description }}</span>
+        </td>
+        <td>
+            <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }">
+                <StudipDate :date="startDate" />
+                –
+                <StudipDate :date="endDate" />
+            </router-link>
+        </td>
+        <td>
+            {{ process.attributes.configuration.anonymous ? $gettext('anonym') : $gettext('nicht anonym') }},
+            {{ reviewType }},
+            {{
+                process.attributes.configuration.automaticPairing
+                    ? $gettext('automatische Zuordnung')
+                    : $gettext('manuelle Zuordnung')
+            }}
+        </td>
+    </tr>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { mapGetters } from 'vuex';
+import StudipDate from '../../StudipDate.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import { AssessmentType, ASSESSMENT_TYPES } from './peer-review/process-configuration';
+import { getProcessStatus, JsonApiSchema, StatusDescriptor } from './peer-review/definitions';
+
+export default Vue.extend({
+    props: {
+        process: {
+            required: true,
+            type: Object,
+        },
+    },
+    components: { StudipDate, StudipIcon },
+    computed: {
+        ...mapGetters({ coursewareContext: 'context' }),
+        cid(): string {
+            return this.coursewareContext.id;
+        },
+        endDate(): Date {
+            return new Date(this.process.attributes['review-end']);
+        },
+        reviewType(): string {
+            const type = <AssessmentType>this.process.attributes.configuration.type;
+            return ASSESSMENT_TYPES[type].short;
+        },
+        startDate(): Date {
+            return new Date(this.process.attributes['review-start']);
+        },
+        status(): StatusDescriptor {
+            return getProcessStatus(<JsonApiSchema>this.process);
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
new file mode 100644
index 00000000000..fee7a2f743f
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
@@ -0,0 +1,120 @@
+<template>
+    <tr>
+        <td>
+            <studip-icon
+                v-if="status.shape !== undefined"
+                :shape="status.shape"
+                :role="status.role"
+                :title="status.description"
+                aria-hidden="true"
+            />
+            <span class="sr-only">{{ status.description }}</span>
+        </td>
+        <td>
+            <span v-if="user">
+                <studip-icon shape="person2" role="info" aria-hidden="true" :title="$gettext('Teilnehmende Person')" />
+                <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span>
+                {{ user.attributes['formatted-name'] }}
+            </span>
+            <span v-if="group">
+                <studip-icon shape="group2" role="info" aria-hidden="true" :title="$gettext('Gruppe')" />
+                <span class="sr-only">{{ $gettext('Gruppe') }}</span>
+                {{ group.attributes['name'] }}
+            </span>
+        </td>
+        <td class="responsive-hidden">
+            <a v-if="task.attributes.submitted" :href="getLinkToElement(element)">
+                {{ element.attributes.title }}
+            </a>
+            <span v-else>{{ element.attributes.title }}</span>
+        </td>
+        <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td>
+        <td>{{ getReadableDate(task.attributes['submission-date']) }}</td>
+        <td>
+            <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" />
+        </td>
+        <td class="responsive-hidden">
+            <button v-show="task.attributes.renewal === 'pending'" class="button" @click="$emit('solve-renewal', task)">
+                {{ $gettext('Anfrage bearbeiten') }}
+            </button>
+            <span v-show="task.attributes.renewal === 'declined'">
+                <studip-icon shape="decline" role="status-red" />
+                {{ $gettext('Anfrage abgelehnt') }}
+            </span>
+            <span v-show="task.attributes.renewal === 'granted'">
+                {{ $gettext('verlängert bis') }}:
+                {{ getReadableDate(task.attributes['renewal-date']) }}
+            </span>
+            <studip-icon
+                v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'"
+                :title="$gettext('Anfrage bearbeiten')"
+                class="edit"
+                shape="edit"
+                role="clickable"
+                @click="$emit('solve-renewal', task)"
+            />
+        </td>
+        <td class="responsive-hidden">
+            <span
+                v-if="feedback"
+                :title="
+                    $gettextInterpolate($gettext('Feedback geschrieben am: %{ date }'), {
+                        date: getReadableDate(feedback.attributes['chdate']),
+                    })
+                "
+            >
+                <studip-icon shape="accept" role="status-green" />
+                {{ $gettext('Feedback gegeben') }}
+                <studip-icon
+                    :title="$gettext('Feedback bearbeiten')"
+                    class="edit"
+                    shape="edit"
+                    role="clickable"
+                    @click="$emit('edit-feedback', feedback)"
+                />
+            </span>
+
+            <button v-show="!feedback && task.attributes.submitted" class="button" @click="$emit('add-feedback', task)">
+                {{ $gettext('Feedback geben') }}
+            </button>
+        </td>
+    </tr>
+</template>
+<script>
+import taskHelper from '../../../mixins/courseware/task-helper.js';
+import { mapGetters } from 'vuex';
+
+export default {
+    mixins: [taskHelper],
+    props: ['task', 'taskGroup'],
+    computed: {
+        ...mapGetters({
+            elementById: 'courseware-structural-elements/byId',
+            feedbackById: 'courseware-task-feedback/byId',
+            statusGroupById: 'status-groups/byId',
+            userById: 'users/byId',
+        }),
+        element() {
+            return this.elementById({ id: this.task.relationships['structural-element'].data.id });
+        },
+        feedback() {
+            const id = this.task.relationships['task-feedback'].data?.id;
+            return id ? this.feedbackById({ id }) : null;
+        },
+        group() {
+            const { id, type } = this.solver;
+            return type === 'status-groups' ? this.statusGroupById({ id }) : null;
+        },
+        solver() {
+            return this.task.relationships.solver.data;
+        },
+        status() {
+            return this.getStatus(this.task);
+        },
+        user() {
+            const { id, type } = this.solver;
+            return type === 'users' ? this.userById({ id }) : null;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
new file mode 100644
index 00000000000..dd25c701bed
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
@@ -0,0 +1,38 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Aufgabe löschen')"
+        :confirmText="$gettext('Löschen')"
+        confirmClass="accept"
+        :closeText="$gettext('Abbrechen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            {{ $gettext('Wollen Sie die Aufgabe wirklich löschen?') }}
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions } from 'vuex';
+
+export default {
+    props: ['taskGroup'],
+    methods: {
+        ...mapActions({
+            deleteTaskGroup: 'courseware-task-groups/delete',
+            setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog'
+        }),
+        onClose() {
+            this.setShowTaskGroupsDeleteDialog(false);
+        },
+        onConfirm() {
+            this.deleteTaskGroup(this.taskGroup).then(() => {
+                this.onClose();
+                this.$router.push({ name: 'task-groups-index' });
+            });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
new file mode 100644
index 00000000000..47c6c5d7605
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
@@ -0,0 +1,109 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Bearbeitungszeit verlängern')"
+        :confirmText="$gettext('Verlängern')"
+        confirmClass="accept"
+        :closeText="$gettext('Abbrechen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <p>
+                    {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }})
+                </p>
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`task-groups-${uid}`"
+                        :label="$gettext('Bearbeitungszeit verlängern bis zum:')"
+                    />
+                    <input
+                        :id="`task-groups-${uid}`"
+                        name="end-date"
+                        type="date"
+                        v-model="localEndDate"
+                        :min="endDateString"
+                        class="size-l"
+                        required
+                    />
+                    <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div>
+                </div>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import LabelRequired from '../../forms/LabelRequired.vue';
+import StudipDate from '../../StudipDate.vue';
+
+const midnight = (_date) => {
+    const date = new Date(_date);
+    date.setHours(0);
+    date.setMinutes(0);
+    date.setSeconds(0);
+    date.setMilliseconds(0);
+    return date;
+};
+
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
+let nextUid = 0;
+
+export default {
+    props: ['taskGroup'],
+    components: {
+        LabelRequired,
+        StudipDate,
+    },
+    data: () => ({ localEndDate: null, uid: nextUid++ }),
+    computed: {
+        endDate() {
+            return midnight(this.taskGroup?.attributes?.['end-date'] ?? new Date());
+        },
+        endDateString() {
+            return dateString(this.endDate);
+        },
+        newDuration() {
+            return this.localEndDate
+                ? Math.floor((midnight(this.localEndDate) - this.startDate) / (1000 * 60 * 60 * 24))
+                : 0;
+        },
+        oldDuration() {
+            return Math.floor((this.endDate - this.startDate) / (1000 * 60 * 60 * 24));
+        },
+        startDate() {
+            return midnight(this.taskGroup.attributes['start-date']);
+        },
+    },
+    methods: {
+        ...mapActions({
+            modifyDeadline: 'tasks/modifyDeadlineOfTaskGroup',
+            setShowDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog'
+        }),
+        onClose() {
+            this.setShowDialog(false);
+        },
+        onConfirm() {
+            const endDate = midnight(this.localEndDate);
+            this.modifyDeadline({ taskGroup: this.taskGroup, endDate });
+            this.onClose();
+        },
+        resetLocalVars() {
+            this.localEndDate = dateString(this.endDate ?? new Date());
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        taskGroup() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
new file mode 100644
index 00000000000..d80a31c2b27
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
@@ -0,0 +1,91 @@
+<template>
+    <StudipDialog
+        v-if="show"
+        :title="$gettext('Aufgabe begutachten')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <component v-bind:is="assessmentComponent" :process="process" :review="review" @answer="onAnswer"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import AssessmentTypeForm from './AssessmentTypeForm.vue';
+import AssessmentTypeFreetext from './AssessmentTypeFreetext.vue';
+import AssessmentTypeTable from './AssessmentTypeTable.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        AssessmentTypeForm,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    data: () => ({
+        assessment: {}
+    }),
+    computed: {
+        ...mapGetters({
+            relatedProcess: 'courseware-peer-review-processes/related',
+        }),
+        assessmentComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return AssessmentTypeForm;
+                case 'freetext':
+                    return AssessmentTypeFreetext;
+                case 'table':
+                    return AssessmentTypeTable;
+                default:
+                    return null;
+            }
+        },
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        process() {
+            return this.relatedProcess({
+                parent: { id: this.review.id, type: this.review.type },
+                relationship: 'process',
+            });
+        },
+    },
+    methods: {
+        ...mapActions({
+            storeAssessment: 'tasks/storeAssessment',
+        }),
+        onAnswer(assessment) {
+            this.assessment = assessment;
+        },
+        onClose() {
+            this.$emit('updateShow', false);
+            this.assessment = {};
+        },
+        onConfirm() {
+            this.$emit('updateShow', false);
+            this.storeAssessment({ review: this.review, assessment: this.assessment });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
new file mode 100644
index 00000000000..d01fb394073
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
@@ -0,0 +1,86 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Gutachten-Form ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        width="800"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <component v-bind:is="editorComponent" v-model="payload"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import EditorForm from './EditorForm.vue';
+import EditorTable from './EditorTable.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { ASSESSMENT_TYPES } from './process-configuration';
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data() {
+        return { localPayload: _.cloneDeep(this.payload) };
+    },
+    computed: {
+        ...mapGetters({}),
+        editorComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return EditorForm;
+                case 'freetext':
+                    return null;
+                case 'table':
+                    return EditorTable;
+                default:
+                    return null;
+            }
+        },
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        defaultPayload() {
+            return ASSESSMENT_TYPES[this.configuration.type].defaultPayload ?? {};
+        },
+        payload: {
+            get() {
+                return _.isEmpty(this.configuration.payload) ? this.defaultPayload : this.configuration.payload;
+            },
+            set(payload) {
+                this.localPayload = payload;
+            },
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', _.cloneDeep(this.localPayload));
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
new file mode 100644
index 00000000000..c5f9da2c8fa
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
@@ -0,0 +1,96 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart" v-for="(criterium, index) in criteria" :key="index">
+                <LabelRequired
+                    :id="`assessment-type-form-${index}`"
+                    :label="criterium.text"
+                    />
+                <p>{{ criterium.description }}</p>
+                <textarea
+                    :id="`assessment-type-form-${index}`"
+                    required
+                    aria-required="true"
+                    v-model="answers[index]"
+                    @change="changeAnswers" />
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { ProcessConfiguration, FormAssessmentPayload } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: {
+            answers: string[];
+        };
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: this.review.attributes.assessment?.answers ?? [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = <FormAssessmentPayload>this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            const answers = this.criteria.map((_, index) => this.answers[index] ?? '');
+            this.$emit('answer', { answers });
+        },
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
new file mode 100644
index 00000000000..843b40820ee
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
@@ -0,0 +1,89 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart">
+                <LabelRequired
+                    id="assessment-type-freetext"
+                    :label="$gettext('Bewertung')"
+                    />
+                <textarea
+                    id="assessment-type-freetext"
+                    required
+                    aria-required="true"
+                    v-model="answer"
+                    @change="changeAnswer" />
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { ProcessConfiguration } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: {
+            answer: string;
+        };
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answer: this.review.attributes.assessment?.answer ?? "",
+        };
+    },
+    methods: {
+        changeAnswer() {
+            const answer = this.answer ?? '';
+            this.$emit('answer', { answer });
+        },
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
new file mode 100644
index 00000000000..d67be8b353c
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
@@ -0,0 +1,138 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart" v-for="(criterium, index) in criteria" :key="index">
+                <LabelRequired :id="`assessment-type-table-${index}`" :label="criterium.text" />
+                <section>
+                    <textarea
+                        :id="`assessment-type-table-${index}`"
+                        required
+                        aria-required="true"
+                        v-model="answers[index].text"
+                        @change="changeAnswers"
+                    />
+
+                    <div>
+                        <label v-for="(text, rating) in ratingLevels" :key="text"
+                            ><input
+                                v-model="answers[index].rating"
+                                :name="`rating-${index}`"
+                                type="radio"
+                                :value="rating + 1"
+                                @change="changeAnswers"
+                            />{{ text }}</label
+                        >
+                    </div>
+                </section>
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { EditorTableCriterium, ProcessConfiguration, TableAssessmentPayload } from './process-configuration';
+
+interface PeerReviewProcessSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        configuration: ProcessConfiguration;
+        'review-start': string;
+        'review-end': string;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+type Answer = {
+    text: string;
+    rating: number;
+};
+
+type Assessment = {
+    answers: Answer[];
+};
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: Assessment;
+        mkdate: string;
+        chdate: string;
+    };
+}
+
+const emptyAssessment = (criteria: EditorTableCriterium[]): Assessment => {
+    return {
+        answers: criteria.map((_) => ({ text: '', rating: 0 })),
+    };
+};
+
+export default Vue.extend({
+    components: { LabelRequired },
+    props: {
+        process: {
+            type: Object as PropType<PeerReviewProcessSchema>,
+            required: true,
+        },
+        review: {
+            type: Object as PropType<PeerReviewSchema>,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: [] as Answer[],
+        };
+    },
+    computed: {
+        criteria(): EditorTableCriterium[] {
+            const payload = <TableAssessmentPayload>this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+        ratingLevels() {
+            return [$gettext('gut'), $gettext('ok'), $gettext('schwach')];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            this.$emit('answer', { answers: this.answers });
+        },
+    },
+    beforeMount() {
+        if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) {
+            this.answers = this.review.attributes.assessment.answers;
+        } else {
+            this.answers = emptyAssessment(this.criteria).answers;
+        }
+    },
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+
+.formpart > section {
+    display: flex;
+}
+
+.formpart > section label {
+    white-space: nowrap;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
new file mode 100644
index 00000000000..8a8b7c49474
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
@@ -0,0 +1,153 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor">
+            <form class="default studipform">
+                <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable>
+                    <template v-slot:title="{ isOpen }">
+                        <template v-if="isOpen">
+                            {{
+                                $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), {
+                                    index: index + 1,
+                                    text: criterium.text,
+                                })
+                            }}
+                        </template>
+                        <template v-else>
+                            {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }}
+                        </template>
+                    </template>
+                    <template #titleplus>
+                        <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" />
+                    </template>
+                    <template #body>
+                        <div class="formpart criterium-text">
+                            <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" />
+                            <input
+                                :id="`editor-form-text-${index}`"
+                                type="text"
+                                v-model="criterium.text"
+                                required
+                                aria-required="true"
+                            />
+                        </div>
+                        <div class="formpart criterium-description">
+                            <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" />
+                            <textarea
+                                :id="`editor-form-description-${index}`"
+                                v-model="criterium.description"
+                                required
+                                aria-required="true"
+                            ></textarea>
+                        </div>
+                    </template>
+                </StudipArticle>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview">
+            <article>
+                <section v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                    <strong>{{ criterium.text }}</strong>
+                    <p>{{ criterium.description }}</p>
+                    <textarea disabled />
+                </section>
+            </article>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import StudipActionMenu from '../../../StudipActionMenu.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import CoursewareTab from '../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../layouts/CoursewareTabs.vue';
+import { EditorFormCriterium, FormAssessmentPayload } from './process-configuration';
+
+export default Vue.extend({
+    components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle },
+    props: {
+        payload: {
+            type: Object as PropType<FormAssessmentPayload>,
+        },
+    },
+    model: {
+        prop: 'payload',
+        event: 'save',
+    },
+    data: () => ({ localCriteria: [] as EditorFormCriterium[] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        actionItems(index: number) {
+            return this.localCriteria.length > 1
+                ? [
+                      {
+                          id: 1,
+                          label: this.$gettext('Kriterium entfernen'),
+                          icon: 'trash',
+                          emit: 'trash',
+                          emitArguments: [index],
+                      },
+                  ]
+                : [];
+        },
+        addLine() {
+            this.localCriteria.push({ text: '', description: '' });
+        },
+        removeLine(lineNumber: number) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text, description }) => ({ text, description }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        payload() {
+            this.resetLocalState();
+        },
+        localCriteria: {
+            handler() {
+                this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) });
+            },
+            deep: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+.cw-peer-review-editor-form--editor form input {
+    max-width: 48em;
+}
+
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.cw-peer-review-editor-form--preview > article {
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+}
+
+.cw-peer-review-editor-form--preview > article > * + * {
+    border-top: 1px solid var(--light-gray-color-40);
+    padding-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
new file mode 100644
index 00000000000..351e96f39d6
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
@@ -0,0 +1,133 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor">
+            <form class="studip studipform">
+                <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index">
+                    <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" />
+                    <input
+                        :id="`editor-table-text-${index}`"
+                        type="text"
+                        v-model="criterium.text"
+                        required
+                        aria-required="true"
+                    />
+                    <button
+                        class="button trash"
+                        type="button"
+                        @click="removeLine(index)"
+                        :disabled="criteria.length === 1"
+                    >
+                        <span class="sr-only">{{ $gettext('Kriterium entfernen') }}</span>
+                    </button>
+                </div>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview">
+            <table class="default">
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Kriterien') }}</th>
+                        <th>{{ $gettext('Bewertung') }}</th>
+                        <th>{{ $gettext('Kommentar') }}</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                        <td>{{ criterium.text }}</td>
+                        <td>
+                            <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text">
+                                <input name="rating" type="radio" disabled />
+                                {{ text }}
+                            </label>
+                        </td>
+                        <td>
+                            <textarea disabled />
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import CoursewareTab from '../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../layouts/CoursewareTabs.vue';
+import { EditorTableCriterium, TableAssessmentPayload } from './process-configuration';
+
+export default Vue.extend({
+    components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipArticle },
+    props: {
+        payload: {
+            type: Object as PropType<TableAssessmentPayload>,
+        },
+    },
+    model: {
+        prop: 'payload',
+        event: 'save',
+    },
+    data: () => ({ localCriteria: [] as EditorTableCriterium[] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        addLine() {
+            this.localCriteria.push({ text: '' });
+        },
+        removeLine(lineNumber: number) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text }) => ({ text }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        payload() {
+            this.resetLocalState();
+        },
+        localCriteria: {
+            handler() {
+                this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) });
+            },
+            deep: true,
+        },
+    },
+});
+</script>
+
+<style scoped>
+form button.trash {
+    min-width: 2em;
+    width: 2em;
+}
+form input {
+    flex-grow: 1;
+    height: 1.7em;
+    max-width: 48em;
+}
+
+form .formpart {
+    display: flex;
+    align-items: center;
+    gap: 1em;
+}
+
+.cw-peer-review-editor-table--preview label {
+    display: block;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue
new file mode 100644
index 00000000000..4abc75a0c8e
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue
@@ -0,0 +1,34 @@
+<template>
+    <div class="cw-peer-review-processes-wrapper">
+        <div v-if="userIsTeacher">
+            <ProcessesList />
+        </div>
+        <div v-else>
+            <PeerReviewAssignments />
+        </div>
+
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <SidebarActionWidget />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import PeerReviewAssignments from './PeerReviewAssignments.vue';
+import ProcessesList from './ProcessesList.vue';
+import SidebarActionWidget from './SidebarActionWidget.vue';
+import { mapGetters } from 'vuex';
+
+export default {
+    components: {
+        PeerReviewAssignments,
+        ProcessesList,
+        SidebarActionWidget,
+    },
+    computed: {
+        ...mapGetters({
+            userIsTeacher: 'userIsTeacher',
+        }),
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue
new file mode 100644
index 00000000000..2f906898ac8
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesShow.vue
@@ -0,0 +1,311 @@
+<template>
+    <div class="cw-peer-review-processes-wrapper">
+        <div class="cw-peer-review-processes-list">
+            <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
+                <template #buttons>
+                    <router-link :to="{ name: 'peer-review-processes-index' }">
+                        <StudipIcon shape="category-task" :size="24" />
+                    </router-link>
+                </template>
+                <template #breadcrumbList>
+                    <li class="cw-peer-review-processes-breadcrumb-item">
+                        <router-link :to="{ name: 'peer-review-processes-index' }">
+                            {{ $gettext('Peer-Reviews') }}
+                        </router-link>
+                    </li>
+                    <li v-if="taskGroup" class="cw-peer-review-processes-breadcrumb-item">
+                        {{ $gettext('Peer-Review-Prozess') }}
+                    </li>
+                </template>
+            </CoursewareRibbon>
+            <div>
+                <CompanionBox
+                    v-if="!canChangeConfiguration"
+                    :msgCompanion="
+                        $gettext(
+                            'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.'
+                        )
+                    "
+                >
+                </CompanionBox>
+
+                <section>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Status') }}</h2>
+                        </header>
+                        <div class="cw-peer-review-processes-status">
+                            <ProcessStatus :process="process" />
+                            <span>{{ processStatus.description }}</span>
+                        </div>
+                        <div>
+                            <span>{{ $gettext('Bearbeitungszeit:') }}</span>
+                            <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                        </div>
+                        <div v-if="canChangeDurationOnly">
+                            <button class="button" @click="onShowDuration">
+                                {{ $gettext('Bearbeitungszeit verlängern') }}
+                            </button>
+                        </div>
+                    </article>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Aufgabe') }}</h2>
+                        </header>
+                        <div>
+                            <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">
+                                {{ taskGroup.attributes.title }}
+                            </router-link>
+                            <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate
+                                :date="new Date(taskGroup.attributes['end-date'])"
+                            />
+                        </div>
+                    </article>
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Einstellungen') }}</h2>
+                        </header>
+                        <div>
+                            <ProcessConfiguration :options="configuration" />
+                        </div>
+                        <div v-if="canChangeConfiguration" class="cw-peer-review-processes-configuration-buttons">
+                            <button class="button" @click="onShowConfiguration">
+                                {{ $gettext('Einstellungen ändern') }}
+                            </button>
+                            <button
+                                class="button"
+                                @click="onShowAssessmentTypeEditor"
+                                v-if="configuration.type === 'form' || configuration.type === 'table'"
+                            >
+                                {{ $gettext('Bewertungssystem konfigurieren') }}
+                            </button>
+                        </div>
+                    </article>
+
+                    <article>
+                        <header>
+                            <h2>{{ $gettext('Peer-Reviews') }}</h2>
+                        </header>
+                        <div>
+                            <template v-if="isBefore">
+                                <CompanionBox
+                                    v-if="isAutomaticPairing"
+                                    :msgCompanion="
+                                        $gettext(
+                                            'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.'
+                                        )
+                                    "
+                                >
+                                </CompanionBox>
+                                <CompanionBox
+                                    v-else
+                                    mood="pointing"
+                                    :msgCompanion="
+                                        $gettext(
+                                            'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.'
+                                        )
+                                    "
+                                >
+                                    <template #companionActions>
+                                        <button class="button" @click="onShowPairingEditor">
+                                            {{ $gettext('Paarungen manuell festlegen') }}
+                                        </button>
+                                    </template>
+                                </CompanionBox>
+                            </template>
+                            <PeerReviewList :process="process" :task-group="taskGroup" />
+                        </div>
+                    </article>
+                </section>
+            </div>
+        </div>
+        <AssessmentTypeEditorDialog
+            v-model="showAssessmentTypeEditor"
+            :process="process"
+            @update="onUpdateAssessmentType"
+        />
+        <PairingEditorDialog v-model="showPairingEditor" :process="process" @update="onUpdatePairing" />
+        <ProcessEditDialog
+            v-if="showPeerReviewProcessEdit"
+            :process="process"
+            @update="onUpdatePeerReviewProcess"
+            @close="showPeerReviewProcessEdit = false"
+        />
+        <ProcessDurationDialog v-model="showPeerReviewProcessDuration" :process="process" @update="onUpdateDuration" />
+
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <SidebarActionWidget />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import AssessmentTypeEditorDialog from './AssessmentTypeEditorDialog.vue';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import CoursewareRibbon from '../../structural-element/CoursewareRibbon.vue';
+import PairingEditorDialog from './PairingEditorDialog.vue';
+import PeerReviewList from './PeerReviewList.vue';
+import ProcessConfiguration from './ProcessConfiguration.vue';
+import ProcessDurationDialog from './ProcessDurationDialog.vue';
+import ProcessEditDialog from './ProcessEditDialog.vue';
+import ProcessStatus from './ProcessStatus.vue';
+import SidebarActionWidget from './SidebarActionWidget.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipMessageBox from '../../../StudipMessageBox.vue';
+import { getProcessStatus, ProcessStatus as Status } from './definitions';
+
+export default {
+    components: {
+        AssessmentTypeEditorDialog,
+        CompanionBox,
+        CoursewareRibbon,
+        PairingEditorDialog,
+        PeerReviewList,
+        ProcessConfiguration,
+        ProcessDurationDialog,
+        ProcessEditDialog,
+        ProcessStatus,
+        SidebarActionWidget,
+        StudipArticle,
+        StudipDate,
+        StudipMessageBox,
+    },
+    props: {
+        id: {
+            type: String,
+            required: true,
+        },
+    },
+    data: () => ({
+        showAssessmentTypeEditor: false,
+        showPairingEditor: false,
+        showPeerReviewProcessDuration: false,
+        showPeerReviewProcessEdit: false,
+    }),
+    computed: {
+        ...mapGetters({
+            getProcess: 'courseware-peer-review-processes/byId',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        canChangeConfiguration() {
+            return this.isBefore;
+        },
+        canChangeDurationOnly() {
+            return this.processStatus.status === Status.Active;
+        },
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        isBefore() {
+            return this.processStatus.status === Status.Before;
+        },
+        isAutomaticPairing() {
+            return this.configuration.automaticPairing;
+        },
+        owner() {
+            return this.relatedUsers({ parent: this.process, relationship: 'owner' });
+        },
+        peerReviews() {
+            const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+            console.debug(result);
+            return result;
+        },
+        process() {
+            return this.getProcess({ id: this.id });
+        },
+        processStatus() {
+            return getProcessStatus(this.process);
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map(({ id, type }) => {
+                return [id, type];
+            });
+        },
+        startDate() {
+            return new Date(this.process.attributes['review-start']);
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+        tasks() {
+            return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' });
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+            replacePairings: 'tasks/replacePairings',
+            updatePeerReviewProcess: 'tasks/updatePeerReviewProcess',
+        }),
+        loadPeerReviews() {
+            return this.loadRelatedPeerReviews({
+                parent: this.process,
+                relationship: 'peer-reviews',
+                options: { include: 'reviewer,task' },
+            });
+        },
+        onShowAssessmentTypeEditor() {
+            this.showAssessmentTypeEditor = true;
+        },
+        onShowConfiguration() {
+            this.showPeerReviewProcessEdit = true;
+        },
+        onShowDuration() {
+            this.showPeerReviewProcessDuration = true;
+        },
+        onShowPairingEditor() {
+            this.showPairingEditor = true;
+        },
+        onUpdateAssessmentType(payload) {
+            const configuration = this.process.attributes.configuration;
+            configuration.payload = payload;
+
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showAssessmentTypeEditor = false)
+            );
+        },
+        onUpdateDuration(duration) {
+            const configuration = { ...this.process.attributes.configuration, duration };
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showPeerReviewProcessDuration = false)
+            );
+        },
+        onUpdatePairing(pairings) {
+            this.replacePairings({ process: this.process, pairings })
+                .then(() => this.loadPeerReviews())
+                .then(() => (this.showPairingEditor = false))
+                .catch((error) => {
+                    console.error('Could not replace pairings.', error);
+                });
+        },
+        onUpdatePeerReviewProcess({ configuration }) {
+            this.updatePeerReviewProcess({ process: this.process, configuration }).then(
+                () => (this.showPeerReviewProcessEdit = false)
+            );
+        },
+    },
+    async mounted() {
+        await this.loadPeerReviews();
+    },
+};
+</script>
+
+<style>
+.cw-peer-review-processes-breadcrumb-item {
+    white-space: nowrap;
+}
+.cw-peer-review-processes-status,
+.cw-peer-review-processes-configuration-buttons {
+    display: flex;
+    gap: 0.25rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
new file mode 100644
index 00000000000..1b627848df9
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
@@ -0,0 +1,224 @@
+<template>
+    <div>
+        <form class="default">
+            <div>
+                <label>
+                    {{ $gettext('verfasst von') }}
+                    <select v-model="selectedSubmitter" size="10">
+                        <option v-for="solver in selectableSubmitters" :value="solver">
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <label>
+                    {{ $gettext('begutachtet von') }}
+                    <select v-model="selectedReviewer" size="10">
+                        <option v-for="solver in selectableReviewers" :value="solver">
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <div>
+                    <div>{{ $gettext('Paarungen') }}</div>
+                    <div>
+                        <button
+                            class="button button-icon"
+                            type="button"
+                            :disabled="!(selectedSubmitter && selectedReviewer)"
+                            @click="onAdd"
+                        >
+                            <StudipIcon shape="arr_2right" role="info_alt" />
+                            <StudipIcon shape="arr_2right" />
+                            <span class="sr-only">{{ $gettext('Hinzufügen') }}</span>
+                        </button>
+                        <table>
+                            <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index">
+                                <td>
+                                    <UserAvatar
+                                        v-if="submitter.type === 'users'"
+                                        :avatar-url="submitter.meta.avatar.small"
+                                        :formatted-name="submitter.attributes['formatted-name']"
+                                    />
+                                    <span v-if="submitter.type === 'status-groups'">
+                                        {{ submitter.attributes.name }}
+                                    </span>
+                                </td>
+
+                                <td><span>»</span></td>
+                                <td>
+                                    <UserAvatar
+                                        v-if="reviewer.type === 'users'"
+                                        :avatar-url="reviewer.meta.avatar.small"
+                                        :formatted-name="reviewer.attributes['formatted-name']"
+                                    />
+                                    <span v-if="reviewer.type === 'status-groups'">
+                                        {{ reviewer.attributes.name }}
+                                    </span>
+                                </td>
+                                <td>
+                                    <button @click="() => onTrash(index)" class="button button-icon" type="button">
+                                        <StudipIcon shape="trash" role="info_alt" />
+                                        <StudipIcon shape="trash" />
+                                        <span class="sr-only">{{ $gettext('Entfernen') }}</span>
+                                    </button>
+                                </td>
+                            </tr>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapGetters } from 'vuex';
+import StudipIcon from '../../../StudipIcon.vue';
+import UserAvatar from '../../../StudipUserAvatar.vue';
+
+export default {
+    model: {
+        prop: 'pairings',
+        event: 'update',
+    },
+    components: { StudipIcon, UserAvatar },
+    props: {
+        pairings: {
+            type: Array,
+            required: true,
+        },
+        solvers: {
+            type: Array,
+            default: [],
+        },
+    },
+    data() {
+        return {
+            localPairings: [],
+            selectedSubmitter: null,
+            selectedReviewer: null,
+        };
+    },
+    computed: {
+        selectableReviewers() {
+            const selected = this.localPairings.map(({ reviewer }) => reviewer.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+        selectableSubmitters() {
+            const selected = this.localPairings.map(({ submitter }) => submitter.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+    },
+    methods: {
+        isStatusGroup(object) {
+            return object.type === 'status-groups';
+        },
+        isUser(object) {
+            return object.type === 'users';
+        },
+        onAdd() {
+            this.localPairings.push({
+                reviewer: this.selectedReviewer,
+                submitter: this.selectedSubmitter,
+            });
+            this.selectedReviewer = null;
+            this.selectedSubmitter = null;
+        },
+        onTrash(index) {
+            this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)];
+        },
+        resetLocalState() {
+            this.localPairings = [...this.pairings];
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        localPairings(newP, oldP) {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.$emit('update', [...this.localPairings]);
+            }
+        },
+        pairings() {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.resetLocalState();
+            }
+        },
+        selectedReviewer() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedSubmitter = null;
+            }
+        },
+        selectedSubmitter() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedReviewer = null;
+            }
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1rem;
+}
+
+form > * {
+    flex-grow: 1;
+}
+
+form > :nth-child(1) select,
+form > :nth-child(2) select {
+    max-width: 15rem;
+}
+
+form > :nth-child(3) {
+    flex-basis: 15rem;
+}
+
+tr > :nth-child(2) {
+    padding-inline: 0.5rem;
+}
+
+button.button-icon {
+    min-width: auto;
+    line-height: 2rem;
+    padding: 0;
+    width: 2rem;
+}
+button.button-icon > img {
+    vertical-align: middle;
+}
+button.button-icon > img:first-child {
+    display: none;
+}
+button.button-icon:hover > img:first-child {
+    display: inline;
+}
+button.button-icon > img:first-child {
+    display: hidden;
+}
+button.button-icon:hover > img:nth-child(2) {
+    display: none;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
new file mode 100644
index 00000000000..008891a4739
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
@@ -0,0 +1,89 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Zuordnungen festlegen')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="800"
+        width="800"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <pre>{{ JSON.stringify(pairings) }}</pre>
+            <PairingEditor v-if="pairings" v-model="pairings" :solvers="solvers" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import PairingEditor from './PairingEditor.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+
+const objId = ({ id, type }) => ({ id, type });
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        PairingEditor,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data() {
+        return {
+            pairings: [],
+        };
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        reviewPairs() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({
+                reviewer: this.getObject(review.relationships.reviewer.data),
+                submitter: this.getObject(review.relationships.submitter.data),
+            }));
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver));
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+    },
+    methods: {
+        getObject({ type, id }) {
+            return this.$store.getters[`${type}/byId`]({ id });
+        },
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm() {
+            this.$emit('update', this.pairings);
+        },
+    },
+    watch: {
+        show() {
+            if (this.show) {
+                this.pairings = this.reviewPairs;
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
new file mode 100644
index 00000000000..9c4b3bdcf8d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
@@ -0,0 +1,102 @@
+<template>
+    <div>
+        <p>
+            Es gibt im Moment in diese Mannschaft, oh, einige Spieler vergessen ihnen Profi was sie sind. Ich lese nicht
+            sehr viele Zeitungen, aber ich habe gehört viele Situationen. Erstens: wir haben nicht offensiv gespielt. Es
+            gibt keine deutsche Mannschaft spielt offensiv und die Name offensiv wie Bayern. Letzte Spiel hatten wir in
+            Platz drei Spitzen: Elber, Jancka und dann Zickler. Wir müssen nicht vergessen Zickler. Zickler ist eine
+            Spitzen mehr, Mehmet eh mehr Basler.
+        </p>
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>Aufgabe</th>
+                    <th>Bearbeitungszeit</th>
+                    <th><span class="sr-only">Aktionen</span></th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="review in peerReviews" :key="review.id">
+                    <td>
+                        <a :href="elementUrls[review.id]">
+                            {{ taskGroups[review.id].attributes.title }}
+                        </a>
+                    </td>
+
+                    <td>
+                        <StudipDate :date="new Date(processes[review.id].attributes['review-start'])" />
+                        –
+                        <StudipDate :date="new Date(processes[review.id].attributes['review-end'])" />
+                    </td>
+                    <td class="actions">...</td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import StudipDate from '../../../StudipDate.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+
+export default {
+    components: { StudipDate },
+    props: {},
+    mixins: [taskHelper],
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedStructuralElement: 'courseware-structural-elements/related',
+            relatedTask: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        elementUrls() {
+            return this.peerReviews.reduce((memo, review) => {
+                const task = this.tasks[review.id];
+                const element = this.relatedStructuralElement({ parent: task, relationship: 'structural-element' });
+                memo[review.id] = this.getLinkToElement(element);
+                return memo;
+            }, {});
+        },
+        peerReviews() {
+            const course = { type: 'courses', id: this.context.id };
+            return this.relatedPeerReviews({ parent: course, relationship: 'courseware-peer-reviews' });
+        },
+        processes() {
+            return this.peerReviews.reduce((memo, review) => {
+                memo[review.id] = this.relatedPeerReviewProcesses({ parent: review, relationship: 'process' });
+                return memo;
+            }, {});
+        },
+        taskGroups() {
+            return this.peerReviews.reduce((memo, review) => {
+                const process = this.processes[review.id];
+                memo[review.id] = this.relatedTaskGroups({ parent: process, relationship: 'task-group' });
+                return memo;
+            }, {});
+        },
+        tasks() {
+            return this.peerReviews.reduce((memo, review) => {
+                memo[review.id] = this.relatedTask({ parent: review, relationship: 'task' });
+                return memo;
+            }, {});
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+        }),
+    },
+    mounted() {
+        const parent = { type: 'courses', id: this.context.id };
+        const relationship = 'courseware-peer-reviews';
+        const options = {
+            include: 'process,task.structural-element,task.task-group',
+        };
+        this.loadRelatedPeerReviews({ parent, relationship, options });
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
new file mode 100644
index 00000000000..ffd140b9c2d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
@@ -0,0 +1,53 @@
+<template>
+    <div v-if="peerReviews && peerReviews.length > 0">
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>{{ $gettext("Aufgabe") }}</th>
+                    <th>{{ $gettext("verfasst von") }}</th>
+                    <th>{{ $gettext("begutachtet von") }}</th>
+                    <th>&nbsp;</th>
+                </tr>
+            </thead>
+            <tbody>
+                <PeerReviewListItem
+                    v-for="review in peerReviews"
+                    :review="review"
+                    :key="review.id"
+                    :process="process"
+                    :task-group="taskGroup"
+                    />
+            </tbody>
+        </table>
+    </div>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { mapGetters } from 'vuex';
+import { JsonApiSchema } from './definitions';
+import PeerReviewListItem from './PeerReviewListItem.vue';
+import ProgressIndicator from '../../../StudipProgressIndicator.vue';
+
+export default Vue.extend({
+    components: { PeerReviewListItem, ProgressIndicator },
+    props: {
+        process: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+        taskGroup: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+        }),
+        peerReviews() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
new file mode 100644
index 00000000000..2ef2ebd48f8
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
@@ -0,0 +1,118 @@
+<template>
+    <tr>
+        <td>
+            <a :href="getLinkToElement(element)">
+                {{ taskGroup.attributes.title }}
+            </a>
+        </td>
+        <td>
+            <a v-if="isUser(submitter)" :href="userProfile(submitter)">
+                <UserAvatar
+                    :avatar-url="submitter.meta.avatar.small"
+                    :formatted-name="submitter.attributes['formatted-name']"
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(submitter)">
+                {{ submitter.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <a v-if="isUser(submitter)" :href="userProfile(reviewer)">
+                <UserAvatar
+                    :avatar-url="reviewer.meta.avatar.small"
+                    :formatted-name="reviewer.attributes['formatted-name']"
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(reviewer)">
+                {{ reviewer.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <button class="button" @click="onShowAssessment" :disabled="canShowReview">{{ $gettext('Gutachten anzeigen') }}</button>
+        </td>
+    </tr>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import UserAvatar from '@/vue/components/StudipUserAvatar.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+import { getProcessStatus, ProcessStatus } from './definitions';
+
+export default {
+    mixins: [taskHelper],
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+        taskGroup: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: { UserAvatar },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedStructuralElement: 'courseware-structural-elements/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedStatusGroups: 'status-groups/related',
+            relatedUsers: 'users/related',
+        }),
+        canShowReview() {
+            return getProcessStatus(this.process).status !== ProcessStatus.After;
+        },
+        element() {
+            const parent = { id: this.task.id, type: this.task.type };
+            const relationship = 'structural-element';
+            return this.relatedStructuralElement({ parent, relationship });
+        },
+        reviewer() {
+            const user = this.relatedUsers({ parent: this.review, relationship: 'reviewer' });
+            if (user) {
+                return user;
+            }
+            const statusGroup = this.relatedStatusGroups({ parent: this.review, relationship: 'reviewer' });
+            return statusGroup;
+        },
+        submitter() {
+            const user = this.relatedUsers({ parent: this.task, relationship: 'solver' });
+            if (user) {
+                return user;
+            }
+            const statusGroup = this.relatedStatusGroups({ parent: this.task, relationship: 'solver' });
+            return statusGroup;
+        },
+        task() {
+            const parent = { id: this.review.id, type: this.review.type };
+            const relationship = 'task';
+            return this.relatedTasks({ parent, relationship });
+        },
+    },
+    methods: {
+        isUser(object) {
+            return object.type === 'users';
+        },
+        onShowAssessment() {
+            console.debug('NYI');
+        },
+        statusGroupUrl(statusGroup) {
+            const cid = this.context.id;
+            return window.STUDIP.URLHelper.getURL(
+                'dispatch.php/course/statusgroups',
+                { cid, contentbox_open: statusGroup.id },
+                true
+            );
+        },
+        userProfile(user) {
+            const username = user.attributes.username;
+            return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username }, true);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
new file mode 100644
index 00000000000..63775280034
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
@@ -0,0 +1,40 @@
+<template>
+    <ul>
+        <li v-if="options.anonymous">{{ $gettext('Anonymes Review') }}</li>
+        <li v-else>{{ $gettext('Offenes Review') }}</li>
+
+        <li>
+            {{
+                $gettextInterpolate($gettext('%{n} Tage Zeit für das Review'), {
+                    n: options.duration,
+                })
+            }}
+        </li>
+
+        <li>
+            {{ reviewTypes[options.type].long }}
+        </li>
+
+        <li v-if="options.automaticPairing">
+            {{ $gettext('Zusammenstellung der Review-Paarungen durch das Programm') }}
+        </li>
+        <li v-else>{{ $gettext('Zusammenstellung der Review-Paarungen durch die Lehrenden') }}</li>
+    </ul>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration';
+
+export default Vue.extend({
+    props: {
+        options: {
+            required: true,
+            type: Object as PropType<ProcessConfiguration>,
+        },
+    },
+    computed: {
+        reviewTypes: () => ASSESSMENT_TYPES,
+    },
+});
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
new file mode 100644
index 00000000000..3bd551e7b58
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
@@ -0,0 +1,65 @@
+<template>
+    <StudipDialog
+        :title="$gettext('Peer-Review-Prozess anlegen')"
+        :confirmText="$gettext('Anlegen')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="create"
+    >
+        <template #dialogContent>
+            <header>
+                <h2>
+                    {{
+                        $gettextInterpolate($gettext('Aufgabe "%{title}"'), {
+                            title: taskGroup.attributes.title,
+                        })
+                    }}
+                    <span>
+                        (<StudipDate :date="new Date(taskGroup.attributes['start-date'])" />
+                        –
+                        <StudipDate :date="new Date(taskGroup.attributes['end-date'])" />)
+                    </span>
+                </h2>
+            </header>
+
+            <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default Vue.extend({
+    components: { ProcessCreateForm, StudipDate, StudipDialog },
+    props: ['taskGroup'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    methods: {
+        create() {
+            this.$emit('create', { ...this.configuration });
+        },
+        updateConfiguration(configuration: ProcessConfiguration) {
+            this.changed = true;
+            this.configuration = configuration;
+        },
+    },
+});
+</script>
+
+<style scoped>
+header {
+    margin-block-end: 2rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
new file mode 100644
index 00000000000..0bf0b6482db
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
@@ -0,0 +1,258 @@
+<template>
+    <form class="default" @submit.prevent="">
+        <div class="peer-review-process-create-form-switcher">
+            <button
+                class="button"
+                :class="{ active: !showCustomConfiguration }"
+                @click="showCustomConfiguration = false"
+            >
+                {{ $gettext('Einfach') }}
+            </button>
+            <button class="button" :class="{ active: showCustomConfiguration }" @click="showCustomConfiguration = true">
+                {{ $gettext('Erweitert') }}
+            </button>
+        </div>
+
+        <section class="peer-review-process-create-form-type-cards" v-if="!showCustomConfiguration">
+            <article
+                v-for="(configurationSet, index) in configurationSets"
+                :key="index"
+                :class="{ selected: selectedConfigurationSet === index }"
+            >
+                <h2>{{ configurationSet.name }}</h2>
+
+                <button
+                    class="button"
+                    :class="{ accept: selectedConfigurationSet === index }"
+                    :disabled="selectedConfigurationSet === index"
+                    type="button"
+                    @click="selectConfigurationSet(index)"
+                >
+                    {{ selectedConfigurationSet === index ? $gettext('Ausgewählt') : $gettext('Auswählen') }}
+                </button>
+
+                <PeerReviewProcessConfiguration :options="configurationSet.configuration" />
+            </article>
+        </section>
+
+        <ContentBox
+            v-else
+            class="peer-review-process-create-form-custom-configuration"
+            :title="$gettext('Erweiterte Einstellungen')"
+        >
+            <div class="custom-configuration">
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-anonymous`"
+                        :label="$gettext('Anonymes oder offenes Review:')"
+                    >
+                        <select
+                            v-model="localConfiguration.anonymous"
+                            :id="`peer-review-process-create-form-${uid}-anonymous`"
+                            @change="customizeConfiguration"
+                        >
+                            <option :value="true">{{ $gettext('anonym') }}</option>
+                            <option :value="false">{{ $gettext('offen') }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-duration`"
+                        :label="$gettext('Bearbeitungszeitraum in Tagen:')"
+                    >
+                        <select
+                            v-model.number="localConfiguration.duration"
+                            :id="`peer-review-process-create-form-${uid}-duration`"
+                            @change="customizeConfiguration"
+                        >
+                            <option v-for="i in 21" :key="i">{{ i }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-create-form-${uid}-type`"
+                        :label="$gettext('Art des Reviews:')"
+                    >
+                        <select
+                            v-model="localConfiguration.type"
+                            :id="`peer-review-process-create-form-${uid}-type`"
+                            @change="onChangeType"
+                        >
+                            <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key">
+                                {{ short }}
+                            </option>
+                        </select>
+                    </LabelRequired>
+                </div>
+
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-true-process-create-form-${uid}-anonymous`"
+                        :label="$gettext('Review-Paarungen')"
+                    >
+                        <select
+                            v-model="localConfiguration.automaticPairing"
+                            :id="`peer-review-process-create-form-${uid}-automatic-pairing`"
+                            @change="customizeConfiguration"
+                        >
+                            <option :value="true">{{ $gettext('Zufall') }}</option>
+                            <option :value="false">{{ $gettext('Manuell') }}</option>
+                        </select>
+                    </LabelRequired>
+                </div>
+            </div>
+        </ContentBox>
+    </form>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import ContentBox from '../../../StudipContentBox.vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import PeerReviewProcessConfiguration from './ProcessConfiguration.vue';
+import { ASSESSMENT_TYPES, CONFIGURATION_SETS, ProcessConfiguration } from './process-configuration';
+
+let nextId = 0;
+
+export default Vue.extend({
+    components: { ContentBox, LabelRequired, PeerReviewProcessConfiguration },
+    props: {
+        configuration: {
+            required: true,
+            type: Object as PropType<ProcessConfiguration>,
+        },
+        custom: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    data() {
+        return {
+            localConfiguration: { ...this.configuration },
+            selectedConfigurationSet: -1,
+            showCustomConfiguration: this.custom,
+            uid: nextId++,
+        };
+    },
+    computed: {
+        reviewTypes: () => ASSESSMENT_TYPES,
+        configurationSets: () => CONFIGURATION_SETS,
+    },
+    methods: {
+        customizeConfiguration() {
+            this.selectedConfigurationSet = -1;
+            this.update();
+        },
+        onChangeType() {
+            this.localConfiguration.payload =
+                this.localConfiguration.type === this.configuration.type
+                    ? this.configuration.payload
+                    : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload;
+            this.customizeConfiguration();
+        },
+        resetData() {
+            this.localConfiguration = { ...this.configuration };
+        },
+        selectConfigurationSet(configurationSetIndex: number) {
+            this.selectedConfigurationSet = configurationSetIndex;
+            this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration;
+            this.update();
+        },
+        update() {
+            this.$emit('update', this.localConfiguration);
+        },
+    },
+    watch: {
+        configuration() {
+            this.resetData();
+        },
+    },
+});
+</script>
+
+<style scoped lang="scss">
+.peer-review-process-create-form-type-cards {
+    box-sizing: border-box;
+    width: 100%;
+    margin-block: 1.5rem 0;
+
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1.5rem;
+    --threshold: 45rem;
+
+    article {
+        flex-grow: 1;
+        flex-basis: calc((var(--threshold) - 100%) * 999);
+        box-sizing: border-box;
+        padding: 1rem;
+        border: 2px var(--dark-gray-color-20) solid;
+
+        &.selected {
+            border-color: var(--dark-gray-color-80);
+            border-width: 2px;
+        }
+
+        h2 {
+            font-weight: bold;
+            font-size: 1.2rem;
+            margin-block: 1rem 0;
+        }
+        button {
+            margin-block: 1.5rem;
+        }
+        ul {
+            padding-inline: 1em 0;
+        }
+        li {
+            padding-block: 0.5rem;
+        }
+    }
+
+    > :nth-last-child(n + 4),
+    > :nth-last-child(n + 4) ~ * {
+        flex-basis: 100%;
+    }
+}
+
+.peer-review-process-create-form-type-cards + section {
+    text-align: center;
+    margin-block-end: 1.5rem;
+}
+
+.peer-review-process-create-form-custom-configuration {
+    margin-block: 1.5rem;
+}
+
+.custom-configuration {
+    padding: 1rem;
+}
+
+.peer-review-process-create-form-switcher {
+    display: flex;
+    justify-content: center;
+}
+
+.peer-review-process-create-form-switcher button {
+    margin: 0;
+}
+
+.peer-review-process-create-form-switcher button + button {
+    border-left: none;
+}
+
+.peer-review-process-create-form-switcher button.active {
+    background: var(--base-color);
+    color: var(--white);
+    cursor: default;
+}
+
+.peer-review-process-create-form-switcher button:not(.active):hover {
+    background: var(--white);
+    color: var(--base-color);
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
new file mode 100644
index 00000000000..af6756ff4bb
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
@@ -0,0 +1,122 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Bearbeitungszeit ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <p>
+                    {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }})
+                </p>
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-${uid}`"
+                        :label="$gettext('Bearbeitungszeit verlängern bis zum:')"
+                    />
+                    <input
+                        :id="`peer-review-process-${uid}`"
+                        name="end-date"
+                        type="date"
+                        v-model="localEndDate"
+                        :min="endDateString"
+                        class="size-l"
+                        required
+                    />
+                    <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div>
+                </div>
+            </form>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+
+const midnight = (_date) => {
+    const date = new Date(_date);
+    date.setHours(0);
+    date.setMinutes(0);
+    date.setSeconds(0);
+    date.setMilliseconds(0);
+    return date;
+};
+
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
+let nextUid = 0;
+
+export default {
+    model: {
+        prop: 'show',
+        event: 'updateShow',
+    },
+    components: {
+        LabelRequired,
+        StudipDate,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    data: () => ({ localEndDate: null, uid: nextUid++ }),
+    computed: {
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        endDate() {
+            return midnight(this.process?.attributes?.['review-end'] ?? new Date());
+        },
+        endDateString() {
+            return dateString(this.endDate);
+        },
+        newDuration() {
+            return this.localEndDate
+                ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24))
+                : 0;
+        },
+        oldDuration() {
+            return this.configuration.duration ?? '??';
+        },
+        startDate() {
+            return midnight(this.process.attributes['review-start']);
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', this.newDuration);
+        },
+        resetLocalVars() {
+            this.localEndDate = dateString(this.endDate ?? new Date());
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        process() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
new file mode 100644
index 00000000000..56a8cb74b67
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
@@ -0,0 +1,65 @@
+<template>
+    <StudipDialog
+        :title="title"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="confirm"
+    >
+        <template #dialogContent>
+            <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { mapGetters } from 'vuex';
+import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default Vue.extend({
+    components: { ProcessCreateForm, StudipDialog },
+    props: ['process'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    computed: {
+        ...mapGetters({
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        title() {
+            const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+            return $gettextInterpolate($gettext('Peer-Review-Prozess konfigurieren zur Aufgabe "%{title}"'), {
+                title: taskGroup.attributes.title,
+            });
+        },
+    },
+    methods: {
+        confirm() {
+            this.$emit('update', {
+                process: this.process,
+                configuration: { ...this.configuration },
+            });
+        },
+        updateConfiguration(configuration: ProcessConfiguration) {
+            this.changed = true;
+            this.configuration = configuration;
+        },
+    },
+});
+</script>
+
+<style scoped>
+header {
+    margin-block-end: 2rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
new file mode 100644
index 00000000000..faf366634ae
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
@@ -0,0 +1,40 @@
+<template>
+    <span class="peer-review-process-status">
+        <StudipIcon
+            v-if="status.shape !== undefined"
+            :shape="status.shape"
+            :role="status.role"
+            :title="status.description"
+            aria-hidden="true"
+        />
+        <span class="sr-only">{{ status.description }}</span>
+    </span>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import StudipIcon from '../../../StudipIcon.vue';
+import { JsonApiSchema, getProcessStatus } from './definitions';
+
+export default Vue.extend({
+    components: { StudipIcon },
+    props: {
+        process: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+    },
+    computed: {
+        status() {
+            return getProcessStatus(this.process);
+        },
+    },
+});
+</script>
+
+<style scoped>
+.peer-review-process-status {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
new file mode 100644
index 00000000000..b808c68e0e3
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
@@ -0,0 +1,88 @@
+<template>
+    <table class="default">
+        <thead>
+            <tr>
+                <th>{{ $gettext('Status') }}</th>
+                <th>{{ $gettext('Titel') }}</th>
+                <th>{{ $gettext('Bearbeitungszeit') }}</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr v-for="process in sortedProcesses" :key="process.id">
+                <td>
+                    <ProcessStatus :process="process" />
+                </td>
+                <td>
+                    <router-link :to="{ name: 'peer-review-processes-show', params: { id: process.id } }">
+                        {{
+                            $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), {
+                                title: taskTitle(process),
+                            })
+                        }}
+                    </router-link>
+                </td>
+                <td><StudipDate :date="getStartDate(process)" />–<StudipDate :date="getEndDate(process)" /></td>
+            </tr>
+        </tbody>
+    </table>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import ProcessStatus from './ProcessStatus.vue';
+import StudipDate from '../../../StudipDate.vue';
+
+export default {
+    components: {
+        ProcessStatus,
+        StudipDate,
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedProcesses: 'courseware-peer-review-processes/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            taskGroups: 'courseware-task-groups/all',
+        }),
+        sortedProcesses() {
+            return _.sortBy(this.processes, [({ attributes }) => -new Date(attributes['review-end'])]);
+        },
+        processes() {
+            return this.taskGroups.reduce((memo, parent) => {
+                const result = memo.concat(
+                    this.relatedProcesses({ parent, relationship: 'peer-review-processes' }) ?? []
+                );
+                return result;
+            }, []);
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadCourseMemberships: 'course-memberships/loadRelated',
+            loadCourseStatusGroups: 'status-groups/loadRelated',
+            loadTasks: 'tasks/loadTasksOfCourse',
+        }),
+        getEndDate(process) {
+            return new Date(process.attributes['review-end']);
+        },
+        getStartDate(process) {
+            return new Date(process.attributes['review-start']);
+        },
+        taskTitle(process) {
+            const taskGroup = this.relatedTaskGroups({ parent: process, relationship: 'task-group' });
+            return taskGroup?.attributes.title ?? '';
+        },
+    },
+    mounted() {
+        this.loadTasks({ cid: this.context.id });
+        const parent = { type: 'courses', id: this.context.id };
+        this.loadCourseMemberships({
+            parent,
+            relationship: 'memberships',
+            options: { include: 'user', 'page[offset]': 0, 'page[limit]': 10000, 'filter[permission]': 'autor' },
+        });
+        this.loadCourseStatusGroups({ parent, relationship: 'status-groups' });
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
new file mode 100644
index 00000000000..0a177858031
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
@@ -0,0 +1,230 @@
+<template>
+    <StudipArticle collapsable closed class="cw-peer-review-processes-list-item">
+        <template #title>
+            <ProcessStatus :process="process" />
+            <span class="peer-review-process-title">
+                {{
+                    $gettextInterpolate($gettext('Peer-Review-Prozess zur Aufgabe "%{ title }"'), {
+                        title: taskGroup.attributes.title,
+                    })
+                }}
+            </span>
+        </template>
+
+        <template #titleplus>
+            <span> {{ $gettext('Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> </span>
+        </template>
+
+        <template #body>
+            <section>
+                <article>
+                    <header>
+                        <h2>Status</h2>
+                    </header>
+                    <div>
+                        <ProcessStatus :process="process" />
+                        {{ processStatus.description }}
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>Owner</h2>
+                    </header>
+                    <div>
+                        <span>
+                            <img :src="owner.meta.avatar.small" />
+                        </span>
+                        {{ owner.attributes['formatted-name'] }}
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>Aufgabe</h2>
+                    </header>
+                    <div>
+                        <a :href="`#task-groups-${taskGroup.id}`">{{ taskGroup.attributes.title }}</a>
+                        <StudipDate :date="new Date(taskGroup.attributes['start-date'])" />–<StudipDate :date="new Date(taskGroup.attributes['end-date'])" />
+                    </div>
+                </article>
+                <article>
+                    <header>
+                        <h2>{{ $gettext('Einstellungen') }}</h2>
+                    </header>
+                    <div v-if="!canChangeConfiguration">
+                        <StudipMessageBox hideClose>
+                            {{
+                                $gettext(
+                                    'Der Peer-Review-Prozess hat bereits begonnen. Bis auf die Bearbeitungsdauer können die Einstellungen nicht geändert werden.'
+                                )
+                            }}
+                        </StudipMessageBox>
+                    </div>
+                    <div>
+                        <ProcessConfiguration :options="configuration" />
+                    </div>
+                    <div v-if="canChangeConfiguration">
+                        <button class="button" @click="onShowConfiguration">
+                            {{ $gettext('Einstellungen ändern') }}
+                        </button>
+                        <div v-if="configuration.type === 'form' || configuration.type === 'table'">
+                            <button class="button" @click="onShowAssessmentTypeEditor">
+                                <span v-if="configuration.type === 'form'">
+                                    {{ $gettext('Formular ändern') }}
+                                </span>
+                                <span v-if="configuration.type === 'table'">
+                                    {{ $gettext('Tabelle ändern') }}
+                                </span>
+                            </button>
+                        </div>
+                    </div>
+                    <div v-if="canChangeDurationOnly">
+                        <button class="button" @click="onShowDuration">{{ $gettext('Bearbeitungszeit verlängern') }}</button>
+                    </div>
+                </article>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        {{ $gettext('Peer-Reviews') }}
+                    </template>
+                    <template #body>
+                        <PeerReviewList v-if="!isBefore" :process="process" :task-group="taskGroup" />
+                        <template v-else>
+                            <CompanionBox
+                                v-if="isAutomaticPairing"
+                                :msgCompanion="
+                                    $gettext(
+                                        'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.'
+                                    )
+                                "
+                            >
+                            </CompanionBox>
+                            <CompanionBox
+                                v-else
+                                mood="pointing"
+                                :msgCompanion="
+                                    $gettext(
+                                        'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.'
+                                    )
+                                "
+                            >
+                                <template #companionActions>
+                                    <button class="button" @click="$emit('showPairingEditor', process)">
+                                        {{ $gettext('Paarungen manuell festlegen') }}
+                                    </button>
+                                </template>
+                            </CompanionBox>
+                        </template>
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title> Solvers </template>
+                    <template #body>
+                        <pre>{{ solvers }}</pre>
+                    </template>
+                </StudipArticle>
+            </section>
+        </template>
+    </StudipArticle>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import StudipArticle from '../../../StudipArticle.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipMessageBox from '../../../StudipMessageBox.vue';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import ProcessConfiguration from './ProcessConfiguration.vue';
+import ProcessStatus from './ProcessStatus.vue';
+import PeerReviewList from './PeerReviewList.vue';
+import { getProcessStatus, ProcessStatus as Status } from './definitions';
+
+export default {
+    components: {
+        CompanionBox,
+        PeerReviewList,
+        ProcessConfiguration,
+        ProcessStatus,
+        StudipArticle,
+        StudipDate,
+        StudipMessageBox,
+    },
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+        }),
+        canChangeConfiguration() {
+            return this.isBefore;
+        },
+        canChangeDurationOnly() {
+            return this.processStatus.status === Status.Active;
+        },
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        isBefore() {
+            return this.processStatus.status === Status.Before;
+        },
+        isAutomaticPairing() {
+            return this.configuration.automaticPairing;
+        },
+        owner() {
+            return this.relatedUsers({ parent: this.process, relationship: 'owner' });
+        },
+        peerReviews() {
+            const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+            console.debug(result);
+            return result;
+        },
+        processStatus() {
+            return getProcessStatus(this.process);
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map(({ id, type }) => {
+                return [id, type];
+            });
+        },
+        startDate() {
+            return new Date(this.process.attributes['review-start']);
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+        tasks() {
+            return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' });
+        },
+    },
+    methods: {
+        onShowAssessmentTypeEditor() {
+            this.$emit('showAssessmentTypeEditor', this.process);
+        },
+        onShowConfiguration() {
+            this.$emit('showConfiguration', this.process);
+        },
+        onShowDuration() {
+            this.$emit('showDuration', this.process);
+        },
+    },
+};
+</script>
+
+<style>
+.cw-peer-review-processes-list-item:target {
+    border-color: var(--base-color);
+}
+.peer-review-process-title {
+    margin-inline-start: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts
new file mode 100644
index 00000000000..2e6729edf44
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/definitions.ts
@@ -0,0 +1,57 @@
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+
+export enum ProcessStatus {
+    Before = 'before',
+    After = 'after',
+    Active = 'active',
+}
+
+export interface StatusDescriptor {
+    status: ProcessStatus;
+    shape: string;
+    role: string;
+    description: string;
+}
+
+interface StringDict {
+    [key: string]: string;
+}
+
+export interface JsonApiSchema {
+    id?: string;
+    type: string;
+    attributes: StringDict;
+    meta?: StringDict;
+    relationships?: StringDict;
+}
+
+export function getProcessStatus(process: JsonApiSchema): StatusDescriptor {
+    const now = new Date();
+    const startDate = new Date(process.attributes['review-start']);
+    const endDate = new Date(process.attributes['review-end']);
+
+    if (now < startDate) {
+        return {
+            status: ProcessStatus.Before,
+            shape: 'span-empty',
+            role: 'status-yellow',
+            description: $gettext('Peer-Review-Process noch nicht aktiv'),
+        };
+    }
+
+    if (endDate < now) {
+        return {
+            status: ProcessStatus.After,
+            shape: 'span-full',
+            role: 'status-red',
+            description: $gettext('Peer-Review-Process beendet'),
+        };
+    }
+
+    return {
+        status: ProcessStatus.Active,
+        shape: 'span-empty',
+        role: 'status-green',
+        description: $gettext('Peer-Review-Prozess aktiv'),
+    };
+}
diff --git a/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts
new file mode 100644
index 00000000000..04b952a8477
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts
@@ -0,0 +1,129 @@
+import { $gettext } from '../../../../../assets/javascripts/lib/gettext';
+
+export enum AssessmentType {
+    Form = 'form',
+    Freetext = 'freetext',
+    Table = 'table',
+}
+
+export interface EditorFormCriterium {
+    text: string;
+    description: string;
+}
+
+export interface EditorTableCriterium {
+    text: string;
+}
+
+export type FormAssessmentPayload = { criteria: EditorFormCriterium[] };
+export type TableAssessmentPayload = { criteria: EditorTableCriterium[] };
+export type FreetextAssessmentPayload = {};
+
+export type ProcessConfigurationPayload = FormAssessmentPayload | FreetextAssessmentPayload | TableAssessmentPayload;
+
+export interface ProcessConfiguration {
+    anonymous: boolean;
+    duration: number;
+    automaticPairing: boolean;
+    type: AssessmentType;
+    payload?: ProcessConfigurationPayload;
+}
+
+export interface ConfigurationSet {
+    name: string;
+    configuration: ProcessConfiguration;
+}
+
+export const ASSESSMENT_TYPES = {
+    [AssessmentType.Form]: {
+        short: $gettext('Formular'),
+        long: $gettext('Strukturiertes Bewertungssystem mit detailierten Fragen zur Begutachtung'),
+        defaultPayload: { criteria: defaultCriteriaForm() },
+    },
+    [AssessmentType.Freetext]: {
+        short: $gettext('Freitext'),
+        long: $gettext('Freitextliche Begutachtung'),
+        defaultPayload: { },
+    },
+    [AssessmentType.Table]: {
+        short: $gettext('Tabelle'),
+        long: $gettext('Einfaches Bewertungssystem mit 3 Bewertungsnoten und kurzer Erläuterung'),
+        defaultPayload: { criteria: defaultCriteriaTable() },
+    },
+};
+
+export const CONFIGURATION_SETS: Array<ConfigurationSet> = [
+    {
+        name: $gettext('Kurz und bündig'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Table,
+            payload: ASSESSMENT_TYPES[AssessmentType.Table].defaultPayload,
+        },
+    },
+    {
+        name: $gettext('Strukturiert begleitet'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Form,
+            payload: ASSESSMENT_TYPES[AssessmentType.Form].defaultPayload,
+        },
+    },
+    {
+        name: $gettext('Selbstbestimmt'),
+        configuration: {
+            anonymous: true,
+            duration: 7,
+            automaticPairing: true,
+            type: AssessmentType.Freetext,
+            payload: ASSESSMENT_TYPES[AssessmentType.Freetext].defaultPayload,
+        },
+    },
+];
+
+export function defaultConfiguration(): ProcessConfiguration {
+    return CONFIGURATION_SETS[0].configuration;
+}
+
+function defaultCriteriaForm() {
+    return [
+        {
+            text: $gettext('Aufbau'),
+            description: $gettext(
+                'Wo sind die grundlegenden Abschnitte (Einführung, Schlussfolgerung, Literatur, Zitate, usw.) und sind sie angemessen? Wenn nicht, was fehlt?\nHat der Schreiber verschiedene Überschriftenstile verwendet um die Abschnitte klar zu kennzeichnen? Kurze Erklärung.\nWie wurde der Inhalt geordnet? War er logisch, klar und leicht zu folgen? Kurze Erklärung.'
+            ),
+        },
+        {
+            text: $gettext('Grammatik und Stil'),
+            description: $gettext(
+                'Gibt es grammatische oder orthografische Probleme?\nIst der Schreibstil klar? Sind die Absätze und die enthaltenen Sätze zusammengehörig?'
+            ),
+        },
+        {
+            text: $gettext('Inhalt'),
+            description: $gettext(
+                'Hat der Autor hinreichend verdichtet und die Aufgabe diskutiert? Kurze Erklärung.\nHat der Autor umfassend Material aus Standardquellen benutzt? Wenn nicht, was fehlt?\nHat der Autor auch eigene Gedanken beigetragen, oder hat er mehrheitlich Zusammenfassungen von Veröffentlichungen/Daten zusammengetragen? Kurze Erklärung.'
+            ),
+        },
+        {
+            text: $gettext('Zitate'),
+            description: $gettext(
+                'Hat der Autor Zitatquellen passend und korrekt angebeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.'
+            ),
+        },
+    ];
+}
+
+function defaultCriteriaTable() {
+    return [
+        { text: $gettext('These: Klarheit, Bedeutung') },
+        { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') },
+        { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') },
+        { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') },
+        { text: $gettext('Gesamtwirkung') },
+    ];
+}
diff --git a/resources/vue/components/courseware/tasks/task-groups-helper.js b/resources/vue/components/courseware/tasks/task-groups-helper.js
new file mode 100644
index 00000000000..8a9e4697229
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/task-groups-helper.js
@@ -0,0 +1,31 @@
+import { $gettext } from '../../../../assets/javascripts/lib/gettext';
+
+export function getStatus(taskGroup) {
+    const now = new Date();
+    const startDate = new Date(taskGroup.attributes['start-date']);
+    const endDate = new Date(taskGroup.attributes['end-date']);
+
+    if (startDate <= now && now <= endDate) {
+        return {
+            shape: 'span-3quarter',
+            role: 'status-green',
+            description: $gettext('Die Bearbeitungszeit hat begonnen.'),
+        };
+    }
+
+    if (now < startDate) {
+        return {
+            shape: 'span-empty',
+            role: 'status-yellow',
+            description: $gettext('Die Bearbeitungszeit hat noch nicht begonnen.'),
+        };
+    }
+
+    if (endDate < now) {
+        return {
+            shape: 'span-full',
+            role: 'status-red',
+            description: $gettext('Die Bearbeitungszeit ist beendet.'),
+        };
+    }
+}
diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
index c2f54e9171d..8b5d159d59c 100644
--- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
+++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
@@ -2,6 +2,18 @@
     <sidebar-widget id="courseware-action-widget" :title="$gettext('Aktionen')">
         <template #content>
             <ul class="widget-list widget-links cw-action-widget">
+                <template v-if="taskGroup">
+                    <li v-if="mayModifyDeadline" class="cw-action-widget-task-groups-deadline">
+                        <button @click="modifyDeadline(taskGroup)">
+                            {{ $gettext('Bearbeitungszeit verlängern') }}
+                        </button>
+                    </li>
+                    <li class="cw-action-widget-task-groups-delete">
+                        <button @click="deleteTaskGroup(taskGroup)">
+                            {{ $gettext('Aufgabe löschen') }}
+                        </button>
+                    </li>
+                </template>
                 <li class="cw-action-widget-add">
                     <button @click="setShowTasksDistributeDialog(true)">
                         {{ $gettext('Aufgabe verteilen') }}
@@ -22,10 +34,29 @@ export default {
     components: {
         SidebarWidget,
     },
+    props: ['taskGroup'],
+    computed: {
+        mayModifyDeadline() {
+            return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']);
+        },
+    },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            deleteTaskGroup: 'tasks/setShowTaskGroupsDeleteDialog',
+            modifyDeadline: 'tasks/setShowTaskGroupsModifyDeadlineDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
         }),
-    }
+    },
+};
+</script>
+
+<style scoped>
+.cw-action-widget-task-groups-deadline {
+    background-image: url('../images/icons/blue/date.svg');
+    background-size: 16px;
+}
+.cw-action-widget-task-groups-delete {
+    background-image: url('../images/icons/blue/trash.svg');
+    background-size: 16px;
 }
-</script>
\ No newline at end of file
+</style>
diff --git a/resources/vue/components/forms/LabelRequired.vue b/resources/vue/components/forms/LabelRequired.vue
new file mode 100644
index 00000000000..7a123776c79
--- /dev/null
+++ b/resources/vue/components/forms/LabelRequired.vue
@@ -0,0 +1,22 @@
+<template>
+    <label class="studiprequired" :for="id">
+        <span class="textlabel">{{ label }}</span>
+        <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+        <slot></slot>
+    </label>
+</template>
+
+<script>
+export default {
+    props: {
+        id: {
+            type: String,
+            required: true,
+        },
+        label: {
+            type: String,
+            required: true,
+        },
+    },
+};
+</script>
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index 3309b55ce47..761b7f95e79 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -1,6 +1,7 @@
 import CoursewareModule from './store/courseware/courseware.module';
 import CoursewareStructureModule from './store/courseware/structure.module';
 import CoursewareStructuralElement from './components/courseware/structural-element/CoursewareStructuralElement.vue';
+import CoursewareTasksModule from './store/courseware/courseware-tasks.module';
 import IndexApp from './components/courseware/IndexApp.vue';
 import PluginManager from './components/courseware/plugin-manager.js';
 import Vue from 'vue';
@@ -82,6 +83,7 @@ const mountApp = async (STUDIP, createApp, element) => {
         modules: {
             courseware: CoursewareModule,
             'courseware-structure': CoursewareStructureModule,
+            'tasks': CoursewareTasksModule,
             ...mapResourceModules({
                 names: [
                     'courses',
@@ -93,6 +95,8 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'courseware-containers',
                     'courseware-instances',
                     'courseware-public-links',
+                    'courseware-peer-reviews',
+                    'courseware-peer-review-processes',
                     'courseware-structural-elements',
                     'courseware-structural-element-comments',
                     'courseware-structural-element-feedback',
diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js
index 2f332466d79..94dfef83fe3 100644
--- a/resources/vue/courseware-tasks-app.js
+++ b/resources/vue/courseware-tasks-app.js
@@ -1,5 +1,9 @@
-import TasksApp from './components/courseware/TasksApp.vue';
+import PeerReviewProcessesIndex from './components/courseware/tasks/peer-review/PagesProcessesIndex.vue';
+import PeerReviewProcessesShow from './components/courseware/tasks/peer-review/PagesProcessesShow.vue';
+import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue';
+import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import VueRouter, { RouterView } from 'vue-router';
 import Vuex from 'vuex';
 import CoursewareModule from './store/courseware/courseware.module';
 import CoursewareTasksModule from './store/courseware/courseware-tasks.module';
@@ -17,6 +21,51 @@ const mountApp = async (STUDIP, createApp, element) => {
 
     const httpClient = getHttpClient();
 
+    const routes = [
+        {
+            path: '/',
+            name: 'task-groups-index',
+            component: TaskGroupsIndex,
+        },
+        {
+            path: '/task-groups/:id',
+            name: 'task-groups-show',
+            component: TaskGroupsShow,
+            props: true,
+        },
+        {
+            path: '/peer-review-processes',
+            name: 'peer-review-processes-index',
+            component: PeerReviewProcessesIndex,
+        },
+        {
+            path: '/peer-review-processes/:id',
+            name: 'peer-review-processes-show',
+            component: PeerReviewProcessesShow,
+            props: true,
+        },
+    ];
+
+    const base = new URL(
+        window.STUDIP.URLHelper.getURL(
+            'dispatch.php/course/courseware/tasks',
+            { cid: STUDIP.URLHelper.parameters.cid },
+            true
+        )
+    );
+    const router = new VueRouter({
+        base: base.pathname,
+        mode: 'history',
+        routes,
+    });
+    router.beforeEach((to, from, next) => {
+        if ('cid' in to?.query) {
+            next();
+        } else {
+            next({ ...to, query: { ...to.query, cid: window.STUDIP.URLHelper.parameters.cid } });
+        }
+    });
+
     const store = new Vuex.Store({
         modules: {
             courseware: CoursewareModule,
@@ -33,6 +82,8 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'courseware-block-feedback',
                     'courseware-containers',
                     'courseware-instances',
+                    'courseware-peer-reviews',
+                    'courseware-peer-review-processes',
                     'courseware-structural-elements',
                     'courseware-task-feedback',
                     'courseware-task-groups',
@@ -71,22 +122,18 @@ const mountApp = async (STUDIP, createApp, element) => {
     }
 
     store.dispatch('setUserId', STUDIP.USER_ID);
-    await store.dispatch('users/loadById', {id: STUDIP.USER_ID});
+    await store.dispatch('users/loadById', { id: STUDIP.USER_ID });
     store.dispatch('setHttpClient', httpClient);
     store.dispatch('coursewareContext', {
         id: entry_id,
         type: entry_type,
     });
     await store.dispatch('loadTeacherStatus', STUDIP.USER_ID);
-    store.dispatch('courseware-tasks/loadAll', {
-        options: {
-            'filter[cid]': entry_id,
-            include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
-        },
-    });
+    await store.dispatch('tasks/loadTasksOfCourse', { cid: entry_id });
 
     const app = createApp({
-        render: (h) => h(TasksApp),
+        render: (h) => h(RouterView),
+        router,
         store,
     });
 
diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js
index 0bc694c1226..a0510f7697d 100644
--- a/resources/vue/mixins/courseware/task-helper.js
+++ b/resources/vue/mixins/courseware/task-helper.js
@@ -8,7 +8,7 @@ export default {
             limit.setDate(now.getDate() + 3);
             status.canSubmit = true;
 
-            if (now < submissionDate) {
+            if (now <= submissionDate) {
                 status.shape = 'span-empty';
                 status.role = 'status-green';
                 status.description = this.$gettext('Aufgabe bereit');
@@ -20,7 +20,7 @@ export default {
                     status.description = this.$gettext('Aufgabe muss bald abgegeben werden');
                 }
 
-                if (now >= submissionDate) {
+                if (now > submissionDate) {
                     status.canSubmit = false;
                     status.shape = 'span-full';
                     status.role = 'status-red';
@@ -34,7 +34,7 @@ export default {
                     status.description = this.$gettext('Aufgabe muss bald abgegeben werden');
                 }
 
-                if (now >= renewalDate) {
+                if (now > renewalDate) {
                     status.canSubmit = false;
                     status.shape = 'span-full';
                     status.role = 'status-red';
diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js
index fd5152dfa83..96af9393dd6 100644
--- a/resources/vue/store/courseware/courseware-tasks.module.js
+++ b/resources/vue/store/courseware/courseware-tasks.module.js
@@ -1,5 +1,9 @@
+import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration';
+
 const getDefaultState = () => {
     return {
+        showTaskGroupsDeleteDialog: false,
+        showTaskGroupsModifyDeadlineDialog: false,
         showTasksDistributeDialog: false,
     };
 };
@@ -7,29 +11,158 @@ const getDefaultState = () => {
 const initialState = getDefaultState();
 
 const getters = {
+    showTaskGroupsDeleteDialog(state) {
+        return state.showTaskGroupsDeleteDialog;
+    },
+    showTaskGroupsModifyDeadlineDialog(state) {
+        return state.showTaskGroupsModifyDeadlineDialog;
+    },
     showTasksDistributeDialog(state) {
         return state.showTasksDistributeDialog;
     },
+    taskGroupsByCid(state, getters, rootState, rootGetters) {
+        return (cid) => {
+            return rootGetters['courseware-task-groups/all'].filter(
+                (taskGroup) => taskGroup.relationships.course.data.id === cid
+            );
+        };
+    },
+    tasksByCid(state, getters, rootState, rootGetters) {
+        return (cid) => {
+            const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id);
+
+            return rootGetters['courseware-tasks/all'].filter((task) =>
+                taskGroupIds.includes(task.relationships['task-group'].data.id)
+            );
+        };
+    },
 };
 
 export const state = { ...initialState };
 
 export const actions = {
     // setters
+    setShowTaskGroupsDeleteDialog({ commit }, context) {
+        commit('setShowTaskGroupsDeleteDialog', context);
+    },
+    setShowTaskGroupsModifyDeadlineDialog({ commit }, context) {
+        commit('setShowTaskGroupsModifyDeadlineDialog', context);
+    },
     setShowTasksDistributeDialog({ commit }, context) {
         commit('setShowTasksDistributeDialog', context);
     },
 
     // other actions
+    loadTasksOfCourse({ dispatch }, { cid }) {
+        const options = {
+            'filter[cid]': cid,
+            include:
+                'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes',
+        };
+        return dispatch('courseware-tasks/loadAll', { options }, { root: true });
+    },
+
+    loadTaskGroup({ dispatch }, { id }) {
+        const options = {
+            include: 'lecturer, peer-review-processes',
+        };
+        return dispatch('courseware-task-groups/loadById', { id, options }, { root: true });
+    },
+
+    modifyDeadlineOfTaskGroup({ dispatch }, { taskGroup, endDate }) {
+        taskGroup.attributes['end-date'] = endDate.toISOString();
+
+        return dispatch('courseware-task-groups/update', taskGroup, { root: true });
+    },
+
+    createPeerReviewProcess({ dispatch }, { taskGroup, options }) {
+        const { anonymous, duration, automaticPairing, type, payload } = options;
+        const startDate = new Date(taskGroup.attributes['end-date']);
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + duration);
+
+        const data = {
+            attributes: {
+                configuration: { anonymous, duration, automaticPairing, type, payload },
+                'review-start': startDate.toISOString(),
+                'review-end': endDate.toISOString(),
+            },
+            relationships: {
+                'task-group': {
+                    data: {
+                        type: taskGroup.type,
+                        id: taskGroup.id,
+                    },
+                },
+            },
+        };
+
+        return dispatch('courseware-peer-review-processes/create', data, { root: true });
+    },
+    replacePairings({ dispatch, rootGetters }, { process, pairings }) {
+        const reviews = rootGetters['courseware-peer-reviews/related']({
+            parent: process,
+            relationship: 'peer-reviews',
+        });
+        const relation = ({ id, type }) => ({ data: { id, type } });
+        const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true });
+        const createReview = (pairing) =>
+            dispatch(
+                'courseware-peer-reviews/create',
+                {
+                    type: 'courseware-peer-reviews',
+                    attributes: {},
+                    relationships: {
+                        process: relation(process),
+                        submitter: relation(pairing.submitter),
+                        reviewer: relation(pairing.reviewer),
+                    },
+                },
+                { root: true }
+            );
+
+        return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview)));
+    },
+    updatePeerReviewProcess({ dispatch, rootGetters }, { process, configuration }) {
+        const taskGroup = rootGetters['courseware-task-groups/related']({
+            parent: process,
+            relationship: 'task-group',
+        });
+
+        const startDate = new Date(taskGroup.attributes['end-date']);
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + configuration.duration);
+
+        if (_.isEmpty(configuration.payload)) {
+            configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload;
+        }
+
+        process.attributes.configuration = configuration;
+        process.attributes['review-start'] = startDate.toISOString();
+        process.attributes['review-end'] = endDate.toISOString();
+
+        return dispatch('courseware-peer-review-processes/update', process, { root: true });
+    },
+    storeAssessment({ dispatch, rootGetters }, { review, assessment }) {
+        review.attributes.assessment = assessment;
+        return dispatch('courseware-peer-reviews/update', review, { root: true });
+    },
 };
 
 export const mutations = {
-    setShowTasksDistributeDialog(state, data){
+    setShowTasksDistributeDialog(state, data) {
         state.showTasksDistributeDialog = data;
     },
+    setShowTaskGroupsDeleteDialog(state, data) {
+        state.showTaskGroupsDeleteDialog = data;
+    },
+    setShowTaskGroupsModifyDeadlineDialog(state, data) {
+        state.showTaskGroupsModifyDeadlineDialog = data;
+    },
 };
 
 export default {
+    namespaced: true,
     state,
     actions,
     mutations,
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 89d7da82d7e..ecf4664b72a 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1263,7 +1263,7 @@ export const actions = {
             {
                 id: taskId,
                 options: {
-                    include: 'solver,task-group,task-group.lecturer',
+                    include: 'solver,task-group,task-group.lecturer,peer-reviews.process',
                 },
             },
             { root: true }
diff --git a/tests/jsonapi/PeerReviewProcessesIndexTest.php b/tests/jsonapi/PeerReviewProcessesIndexTest.php
new file mode 100644
index 00000000000..16791a97e99
--- /dev/null
+++ b/tests/jsonapi/PeerReviewProcessesIndexTest.php
@@ -0,0 +1,60 @@
+<?php
+
+use Courseware\PeerReviewProcess as ProcessSorm;
+use JsonApi\Routes\Courseware\PeerReview\ProcessesIndex;
+
+class PeerReviewProcessesIndexTest extends \Codeception\Test\Unit
+{
+    /**
+     * @var \UnitTester
+     */
+    protected $tester;
+
+    protected function _before()
+    {
+        \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
+    }
+
+    protected function _after()
+    {
+    }
+
+    // tests
+    public function testShouldIndexProcesses()
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+        $user = \User::findOneByUsername($credentials['username']);
+        $processes = ProcessSorm::findByUser($user);
+        $count = count($processes);
+        $cid = $count ? $processes[0]->getCourse()->id : null;
+
+        $response = $this->getProcesses($credentials);
+        $this->tester->assertTrue($response->isSuccessfulDocument([200]));
+        $this->tester->assertCount($count, $response->document()->primaryResources());
+    }
+
+    public function testShouldIndexFilteredProcesses()
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+        $user = \User::findOneByUsername($credentials['username']);
+
+        // TODO:
+        $newCourse = \Course::create();
+
+        var_dump(count($user->course_memberships));exit;
+    }
+
+    // **** helper functions ****
+    private function getProcesses($credentials)
+    {
+        $app = $this->tester->createApp($credentials, 'get', '/courseware-peer-review-processes', ProcessesIndex::class);
+
+        return $this->tester->sendMockRequest(
+            $app,
+            $this->tester->createRequestBuilder($credentials)
+            ->setUri('/courseware-peer-review-processes')
+            ->fetch()
+            ->getRequest()
+        );
+    }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 55b45dc2e0f..2ada63c6be2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,9 +1,11 @@
 {
     "compilerOptions": {
-        "target": "es2015",
+        "allowJs": true,
+        "module": "es2020",
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
         "strict": true,
-        "module": "es2015",
-        "moduleResolution": "node"
+        "target": "es2020"
     },
     "include": ["resources/**/*.ts", "resources/**/*.vue"],
     "exclude": ["node_modules"]
diff --git a/webpack.common.js b/webpack.common.js
index 3dd376d6f93..8edc9c6b150 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -172,6 +172,7 @@ module.exports = {
             'jquery-ui/widgets/resizable': 'jquery-ui/ui/widgets/resizable',
             '@': path.resolve(__dirname, 'resources')
         },
+        extensions: ['.ts', '.vue', '.js'],
         fallback: {
             'stream': require.resolve("stream-browserify"),
             'buffer': require.resolve("buffer/")
diff --git a/webpack.dev.js b/webpack.dev.js
index c0cee71c597..92fb8a8e147 100644
--- a/webpack.dev.js
+++ b/webpack.dev.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
 const { merge } = require('webpack-merge');
 const common = require('./webpack.common.js');
 const WebpackNotifierPlugin = require('webpack-notifier');
@@ -10,8 +11,13 @@ const statusesPaths = {
 
 module.exports = merge(common, {
     mode: 'development',
-    devtool: 'eval',
+    devtool: 'eval-cheap-module-source-map',
     plugins: [
+        new webpack.WatchIgnorePlugin({
+            paths:[
+                /\.d\.[cm]ts$/
+            ]
+        }),
         new WebpackNotifierPlugin({
             appID: 'Stud.IP Webpack',
             title: function (params) {
-- 
GitLab