From 364e80a545d0bb56fe5515024f9dce0be716e4d3 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         |  27 +-
 app/views/course/courseware/peer_review.php   |   6 +
 lib/classes/JsonApi/RouteMap.php              |  12 +
 .../JsonApi/Routes/Courseware/Authority.php   |  68 ++-
 .../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/ReviewsIndex.php    |  80 +++
 .../PeerReview/ReviewsOfProcessesIndex.php    |  77 +++
 .../Routes/Courseware/TaskGroupsCreate.php    |  26 +-
 .../Routes/Courseware/TaskGroupsShow.php      |   1 +
 .../JsonApi/Routes/Courseware/TasksIndex.php  |   1 +
 .../JsonApi/Routes/Courseware/TasksShow.php   |   3 +
 .../JsonApi/Routes/Courseware/TasksUpdate.php |  65 +--
 lib/classes/JsonApi/SchemaMap.php             |  12 +-
 .../JsonApi/Schemas/Courseware/PeerReview.php |  75 +++
 .../Schemas/Courseware/PeerReviewProcess.php  |  77 +++
 .../JsonApi/Schemas/Courseware/Task.php       |  38 ++
 .../JsonApi/Schemas/Courseware/TaskGroup.php  |  21 +
 lib/models/Courseware/PeerReview.php          |  88 ++++
 lib/models/Courseware/PeerReviewProcess.php   | 138 +++++
 lib/models/Courseware/StructuralElement.php   |   4 +-
 lib/models/Courseware/Task.php                |  85 ++++
 lib/models/Courseware/TaskGroup.php           |  83 ++-
 lib/models/Statusgruppen.php                  |  13 +
 lib/modules/CoursewareModule.class.php        |  10 +-
 package.json                                  |  15 +-
 .../javascripts/bootstrap/courseware.js       |  11 +
 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       |  79 +++
 resources/vue/components/Datetimepicker.vue   |  12 +-
 resources/vue/components/StudipActionMenu.vue |  78 ++-
 resources/vue/components/StudipArticle.vue    |  63 +++
 resources/vue/components/StudipContentBox.vue |  46 ++
 resources/vue/components/StudipDate.vue       |  27 +
 resources/vue/components/StudipUserAvatar.vue |  32 ++
 .../courseware/CoursewareCollapsibleBox.vue   |   6 +-
 .../CoursewareDashboardStudents.vue           | 481 ------------------
 .../courseware/CoursewareTreeItem.vue         |  21 +-
 .../courseware/tasks/AddFeedbackDialog.vue    |  48 ++
 .../tasks/CoursewareDashboardStudents.vue     | 232 +++++++++
 .../{ => tasks}/CoursewareDashboardTasks.vue  |  12 +-
 .../CoursewareTasksActionWidget.vue           |   6 +-
 .../CoursewareTasksDialogDistribute.vue       |  24 +-
 .../courseware/tasks/EditFeedbackDialog.vue   |  60 +++
 .../courseware/tasks/RenewalDialog.vue        |  79 +++
 .../courseware/tasks/TaskGroupListItem.vue    | 153 ++++++
 .../TaskGroupPeerReviewProcessListItem.vue    |  76 +++
 .../courseware/tasks/TaskGroupTaskItem.vue    | 120 +++++
 .../courseware/{ => tasks}/TasksApp.vue       |   0
 .../tasks/peer-review/AssessmentDialog.vue    |  86 ++++
 .../AssessmentTypeEditorDialog.vue            |  88 ++++
 .../tasks/peer-review/AssessmentTypeForm.vue  | 100 ++++
 .../peer-review/AssessmentTypeFreetext.vue    |  88 ++++
 .../tasks/peer-review/AssessmentTypeTable.vue | 119 +++++
 .../tasks/peer-review/EditorForm.vue          | 141 +++++
 .../tasks/peer-review/EditorTable.vue         | 150 ++++++
 .../tasks/peer-review/PeerReviewApp.vue       |  34 ++
 .../peer-review/PeerReviewAssignments.vue     | 102 ++++
 .../tasks/peer-review/PeerReviewList.vue      |  95 ++++
 .../tasks/peer-review/PeerReviewListItem.vue  | 114 +++++
 .../peer-review/ProcessConfiguration.vue      |  40 ++
 .../tasks/peer-review/ProcessCreateDialog.vue |  65 +++
 .../tasks/peer-review/ProcessCreateForm.vue   | 248 +++++++++
 .../tasks/peer-review/ProcessEditDialog.vue   |  65 +++
 .../tasks/peer-review/ProcessStatus.vue       |  40 ++
 .../tasks/peer-review/ProcessesList.vue       | 137 +++++
 .../tasks/peer-review/ProcessesListItem.vue   | 194 +++++++
 .../tasks/peer-review/SidebarActionWidget.vue |  29 ++
 .../tasks/peer-review/definitions.ts          |  57 +++
 .../peer-review/process-configuration.ts      | 129 +++++
 .../vue/components/forms/LabelRequired.vue    |  22 +
 resources/vue/courseware-index-app.js         |   2 +
 resources/vue/courseware-peer-review-app.js   |  94 ++++
 resources/vue/courseware-tasks-app.js         |  10 +-
 .../vue/mixins/courseware/task-helper.js      |   6 +-
 .../courseware/courseware-tasks.module.js     |  71 ++-
 .../vue/store/courseware/courseware.module.js |   2 +-
 .../jsonapi/PeerReviewProcessesIndexTest.php  |  60 +++
 tsconfig.json                                 |   8 +-
 98 files changed, 5604 insertions(+), 636 deletions(-)
 create mode 100644 .storybook/main.js
 create mode 100644 .storybook/preview-head.html
 create mode 100644 .storybook/preview.js
 create mode 100644 app/views/course/courseware/peer_review.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/ReviewsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.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 (96%)
 rename resources/vue/components/courseware/{ => tasks}/CoursewareTasksActionWidget.vue (83%)
 rename resources/vue/components/courseware/{ => tasks}/CoursewareTasksDialogDistribute.vue (97%)
 create mode 100644 resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/RenewalDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupListItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
 rename resources/vue/components/courseware/{ => tasks}/TasksApp.vue (100%)
 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/PeerReviewApp.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/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/SidebarActionWidget.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/forms/LabelRequired.vue
 create mode 100644 resources/vue/courseware-peer-review-app.js
 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 29c48d9e032..94f19387bc4 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -70,12 +70,28 @@ class Course_CoursewareController extends CoursewareController
 
     public function tasks_action(): void
     {
-        global $perm, $user;
-        $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id);
+        $this->is_teacher = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            Context::getId(),
+            $GLOBALS['user']->id
+        );
+        PageLayout::setTitle(_('Courseware: Aufgaben'));
         Navigation::activateItem('course/courseware/tasks');
         $this->setTasksSidebar();
     }
 
+    public function peer_review_action(): void
+    {
+        $this->is_teacher = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            Context::getId(),
+            $GLOBALS['user']->id
+        );
+        PageLayout::setTitle(_('Courseware: Peer-Reviews'));
+        Navigation::activateItem('course/courseware/peer-review');
+        $this->setPeerReviewSidebar();
+    }
+
     public function activities_action(): void
     {
         global $perm, $user;
@@ -290,6 +306,13 @@ class Course_CoursewareController extends CoursewareController
         SkipLinks::addIndex(_('Aktionen'), 'courseware-action-widget', 21);
     }
 
+    private function setPeerReviewSidebar(): void
+    {
+        $sidebar = Sidebar::Get();
+        $sidebar->addWidget(new VueWidget('courseware-action-widget'));
+        SkipLinks::addIndex(_('Aktionen'), 'courseware-action-widget', 21);
+    }
+
     private function setActivitiesSidebar(): void
     {
         $sidebar = Sidebar::Get();
diff --git a/app/views/course/courseware/peer_review.php b/app/views/course/courseware/peer_review.php
new file mode 100644
index 00000000000..b7b05729d60
--- /dev/null
+++ b/app/views/course/courseware/peer_review.php
@@ -0,0 +1,6 @@
+<div
+    id="courseware-peer-review-app"
+    entry-type="courses"
+    entry-id="<?= htmlReady(Context::getId()) ?>"
+>
+</div>
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 9d94c190dd7..acc5c076dbc 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -521,6 +521,18 @@ 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);
     }
 
     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 9fb281005b5..e68a68e0102 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;
@@ -24,6 +26,7 @@ use Course;
 
 /**
  * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.StaticAccess)
  * @SuppressWarnings(PHPMD.TooManyMethods)
  * @SuppressWarnings(PHPMD.TooManyPublicMethods)
  */
@@ -308,7 +311,8 @@ class Authority
 
     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 +336,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 +361,6 @@ class Authority
         return self::canCreateTaskFeedback($user, $resource);
     }
 
-
     public static function canIndexStructuralElementComments(User $user, StructuralElement $resource)
     {
         return self::canShowStructuralElement($user, $resource);
@@ -407,7 +415,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 +424,6 @@ class Authority
         return self::canUpdateStructuralElementFeedback($user, $resource);
     }
 
-
     public static function canShowTemplate(User $user, Template $resource)
     {
         // templates are for everybody, aren't they?
@@ -490,7 +498,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 +521,6 @@ class Authority
         return $request_user->id === $user->id;
     }
 
-
     public static function canShowClipboard(User $user, Clipboard $resource): bool
     {
         return $resource->user_id === $user->id;
@@ -549,5 +556,54 @@ class Authority
         return self::canIndexClipboardsOfAUser($request_user, $user);
     }
 
+    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
+    {
+        $cid = $taskGroup['seminar_id'];
+        // TODO: Wirklich tutor?
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+    }
+
+    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::canUpdatePeerReviewProcess($user, $process);
+    }
+
+    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'];
+        // TODO: Wirklich tutor?
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->reviewer_id === $user->getId();
+    }
+
+    public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canShowPeerReviewProcess($user, $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..c1981c7a6b3
--- /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;
+
+/**
+ * Displays 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/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/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
index 28c4e9ce65c..a8639698efc 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,7 @@ class TaskGroupsCreate extends JsonApiController
                 'task_group_id' => $taskGroup->getId(),
                 'solver_id' => $solver->getId(),
                 'solver_type' => $this->getSolverType($solver),
-                'submission_date' => $submissionDate->getTimestamp(),
+                'submission_date' => $endDate->getTimestamp(),
             ]);
 
             // copy task template
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/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..4986f4956d1 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,6 +19,8 @@ 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,
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..556f51259e5
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReview extends SchemaProvider
+{
+    const TYPE = 'courseware-peer-reviews';
+
+    const REL_PROCESS = 'process';
+    const REL_REVIEWER = 'reviewer';
+    const REL_SUBMITTER = 'submitter';
+    const REL_TASK = 'task';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'assessment' => $resource['assessment'],
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    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,
+        ];
+
+        $relationships[self::REL_REVIEWER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->getReviewer()),
+            ],
+            self::RELATIONSHIP_DATA => $resource->getReviewer(),
+        ];
+
+        $relationships[self::REL_SUBMITTER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->submitter),
+            ],
+            self::RELATIONSHIP_DATA => $resource->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..2ba69157646
--- /dev/null
+++ b/lib/models/Courseware/PeerReview.php
@@ -0,0 +1,88 @@
+<?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 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();
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function isReviewer(\User $user): bool
+    {
+        switch ($this->reviewer_type) {
+            case 'autor':
+                return $this->reviewer_id === $user->getId();
+            case 'group':
+                return \StatusgruppeUser::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);
+        }
+    }
+}
diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php
new file mode 100644
index 00000000000..d39e3dd8b48
--- /dev/null
+++ b/lib/models/Courseware/PeerReviewProcess.php
@@ -0,0 +1,138 @@
+<?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
+{
+    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 isAutomaticPairing(): bool
+    {
+        if (!isset($this->configuration['automaticPairing'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+
+    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);
+    }
+}
diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php
index d3c77fafe1e..281fad01332 100644
--- a/lib/models/Courseware/StructuralElement.php
+++ b/lib/models/Courseware/StructuralElement.php
@@ -391,7 +391,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject
                         return true;
                     }
 
-                    return $task->userIsASolver($user);
+                    return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user);
                 }
 
                 if ($this->canEdit($user)) {
@@ -1176,6 +1176,6 @@ SQL;
         if ($structuralElements) {
             $storage->addTabularData(_('Courseware Seiten'), 'cw_structural_elements', $structuralElements);
         }
-        
+
     }
 }
diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php
index d8ecb80bf20..304799b4861 100644
--- a/lib/models/Courseware/Task.php
+++ b/lib/models/Courseware/Task.php
@@ -30,6 +30,8 @@ use User;
  * @property \Statusgruppen $group belongs_to Statusgruppen
  * @property \Courseware\TaskFeedback $task_feedback belongs_to Courseware\TaskFeedback
  * @property-read \User|\Statusgruppen|null $solver belongs_to User or Statusgruppen
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class Task extends \SimpleORMap
 {
@@ -74,6 +76,14 @@ 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,
@@ -153,6 +163,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
      */
@@ -183,6 +201,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 d32866390a9..24babd58aa5 100644
--- a/lib/models/Courseware/TaskGroup.php
+++ b/lib/models/Courseware/TaskGroup.php
@@ -2,6 +2,7 @@
 
 namespace Courseware;
 
+use Statusgruppen;
 use User;
 
 /**
@@ -18,12 +19,17 @@ use User;
  * @property int                           $structural_element_id database column
  * @property int                           $solver_may_add_blocks database column
  * @property string                        $title                 database column
+ * @property int                           $start_date            database column
+ * @property int                           $end_date              database column
  * @property int                           $mkdate                database column
  * @property int                           $chdate                database column
  * @property \User                         $lecturer              belongs_to User
  * @property \Course                       $course                belongs_to Course
  * @property \Courseware\StructuralElement $structural_element    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
 {
@@ -49,9 +55,33 @@ 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',
+        ];
+
         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 +89,52 @@ 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']);
     }
 }
diff --git a/lib/models/Statusgruppen.php b/lib/models/Statusgruppen.php
index ebf8e133f00..04e8d89d704 100644
--- a/lib/models/Statusgruppen.php
+++ b/lib/models/Statusgruppen.php
@@ -718,4 +718,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 d085de22eb6..03b69741bc0 100644
--- a/lib/modules/CoursewareModule.class.php
+++ b/lib/modules/CoursewareModule.class.php
@@ -67,6 +67,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/peer_review?cid=' . $courseId)
+        );
         $navigation->addSubNavigation(
             'comments',
             new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId)
@@ -81,11 +85,11 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
     public function getIconNavigation($courseId, $last_visit, $user_id)
     {
         $statement = DBManager::get()->prepare("
-                SELECT COUNT(DISTINCT elem.id) 
-                FROM `cw_structural_elements` AS elem 
+                SELECT COUNT(DISTINCT elem.id)
+                FROM `cw_structural_elements` AS elem
                 INNER JOIN `cw_containers` as container ON (elem.id = container.structural_element_id)
                 INNER JOIN `cw_blocks` as blocks ON (container.id = blocks.container_id)
-                WHERE elem.range_type = 'course' 
+                WHERE elem.range_type = 'course'
                 AND elem.range_id = :range_id
                 AND blocks.payload != ''
                 AND blocks.chdate > :last_visit
diff --git a/package.json b/package.json
index 37aae5d583c..9ad1cde3100 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 @@
         "@playwright/test": "^1.33.0",
         "@johmun/vue-tags-input": "^2.1.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/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js
index d24bd4db392..359963e8495 100644
--- a/resources/assets/javascripts/bootstrap/courseware.js
+++ b/resources/assets/javascripts/bootstrap/courseware.js
@@ -43,6 +43,17 @@ STUDIP.domReady(() => {
         });
     }
 
+    if (document.getElementById('courseware-peer-review-app')) {
+        STUDIP.Vue.load().then(({ createApp }) => {
+            import(
+                /* webpackChunkName: "courseware-peer-review-app" */
+                '@/vue/courseware-peer-review-app.js'
+            ).then(({ default: mountApp }) => {
+                return mountApp(STUDIP, createApp, '#courseware-peer-review-app');
+            });
+        });
+    }
+
     if (document.getElementById('courseware-content-bookmark-app')) {
         STUDIP.Vue.load().then(({ createApp }) => {
             import(
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..ce7638ae373
--- /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" @canel="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..b92fbf977ed
--- /dev/null
+++ b/resources/stories/Courseware/Tasks/PeerReviewProcessCreateDialog.stories.js
@@ -0,0 +1,99 @@
+import PeerReviewProcessCreateDialog from '../../../vue/components/courseware/tasks/PeerReviewProcessCreateDialog.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..712313479b6
--- /dev/null
+++ b/resources/vue/components/DatePicker.vue
@@ -0,0 +1,79 @@
+<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');
+            console.debug({ date });
+            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 87f6dfd2940..f6e7fff0e6f 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,
@@ -62,19 +66,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 67f44a95736..db8b12bf493 100644
--- a/resources/vue/components/StudipActionMenu.vue
+++ b/resources/vue/components/StudipActionMenu.vue
@@ -12,18 +12,37 @@
             <ul class="action-menu-list">
                 <li v-for="item in navigationItems" :key="item.id" class="action-menu-item">
                     <a v-if="item.type === 'link'" v-bind="linkAttributes(item)" v-on="linkEvents(item)">
-                        <studip-icon v-if="item.icon !== false" :shape="item.icon.shape" :role="item.icon.role"></studip-icon>
+                        <StudipIcon
+                            v-if="item.icon !== false"
+                            :shape="item.icon.shape"
+                            :role="item.icon.role"
+                        ></StudipIcon>
                         <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>
@@ -33,14 +52,17 @@
     </div>
     <div v-else>
         <a v-for="item in navigationItems" :key="item.id" v-bind="linkAttributes(item)" v-on="linkEvents(item)">
-            <studip-icon :title="item.label" :shape="item.icon.shape" :role="item.icon.role" :size="20"></studip-icon>
+            <StudipIcon :title="item.label" :shape="item.icon.shape" :role="item.icon.role" :size="20"></StudipIcon>
         </a>
     </div>
 </template>
 
 <script>
+import StudipIcon from './StudipIcon.vue';
+
 export default {
     name: 'studip-action-menu',
+    components: { StudipIcon },
     props: {
         items: Array,
         collapseAt: {
@@ -48,16 +70,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;
 
@@ -71,7 +93,7 @@ export default {
 
             return attributes;
         },
-        linkEvents (item) {
+        linkEvents(item) {
             let events = {};
             if (item.emit) {
                 events.click = (e) => {
@@ -82,26 +104,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(),
@@ -110,7 +134,7 @@ export default {
                 };
             });
         },
-        shouldCollapse () {
+        shouldCollapse() {
             const collapseAt = this.collapseAt ?? this.getStudipConfig('ACTIONMENU_THRESHOLD');
 
             if (collapseAt === false) {
@@ -121,9 +145,11 @@ export default {
             }
             return Number.parseInt(collapseAt) <= this.items.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..f24be466c1c
--- /dev/null
+++ b/resources/vue/components/StudipArticle.vue
@@ -0,0 +1,63 @@
+<template>
+    <article class="studip" :class="{ collapsable, collapsed }">
+        <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"></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/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/CoursewareCollapsibleBox.vue
index c4f9233140c..cd430fc93b8 100644
--- a/resources/vue/components/courseware/CoursewareCollapsibleBox.vue
+++ b/resources/vue/components/courseware/CoursewareCollapsibleBox.vue
@@ -2,18 +2,18 @@
     <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>
 
 <script>
 import StudipIcon from './../StudipIcon.vue';
-
 export default {
     name: 'courseware-collapsible-box',
     components: {
diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue
deleted file mode 100644
index 897388dbb4c..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 './CoursewareCompanionBox.vue';
-import CoursewareDateInput from './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/CoursewareTreeItem.vue b/resources/vue/components/courseware/CoursewareTreeItem.vue
index d2d69af90b0..3ef00f58222 100644
--- a/resources/vue/components/courseware/CoursewareTreeItem.vue
+++ b/resources/vue/components/courseware/CoursewareTreeItem.vue
@@ -23,7 +23,7 @@
                 }"
             >
                 {{ element.attributes?.title || "–" }}
-                <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"
@@ -39,7 +39,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"
@@ -84,11 +84,11 @@
             handle=".cw-sortable-handle"
             v-bind="dragOptions"
             :elementId="element.id"
-            :list="nestedChildren" 
+            :list="nestedChildren"
             :group="{ name: 'g1' }"
             @end="endDrag"
         >
-            <courseware-tree-item 
+            <courseware-tree-item
                 v-for="el in nestedChildren"
                 :key="el.id"
                 :element="el"
@@ -303,6 +303,9 @@ export default {
         complete() {
             return this.itemProgress === 100;
         },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
     },
     methods: {
         ...mapActions({
@@ -329,7 +332,7 @@ export default {
             }
             if (data.oldParent !== data.newParent) {
                 sortArray.splice(data.newPos, 0, {id: data.id, type: 'courseware-structural-elements'});
-            } 
+            }
 
             data.sortArray = sortArray;
             this.$emit('sort', data);
@@ -345,7 +348,7 @@ export default {
                         this.storeKeyboardSorting();
                     } else {
                         this.keyboardSelected = true;
-                        const assistiveLive = 
+                        const assistiveLive =
                             this.$gettextInterpolate(
                                 this.$gettext('%{elementTitle} ausgewählt. Aktuelle Position in der Liste: %{pos} von %{listLength}. Drücken Sie die Aufwärts- und Abwärtspfeiltasten, um die Position zu ändern, die Leertaste zum Ablegen, die Escape-Taste zum Abbrechen. Mit Pfeiltasten links und rechts kann die Position in der Hierarchie verändert werden.'),
                                 { elementTitle: this.element.attributes.title, pos: this.element.attributes.position + 1, listLength: this.siblingCount }
@@ -370,7 +373,7 @@ export default {
                         this.$emit('moveItemPrevLevel', data);
                         break;
                     case 38: // up
-                        e.preventDefault();    
+                        e.preventDefault();
                         this.$emit('moveItemUp', data);
                         break;
                     case 39: // right
@@ -378,7 +381,7 @@ export default {
                         this.$emit('moveItemNextLevel', data);
                         break;
                     case 40: // down
-                        e.preventDefault(); 
+                        e.preventDefault();
                         this.$emit('moveItemDown', data);
                         break;
                 }
@@ -441,7 +444,7 @@ export default {
             }
             this.$emit('sort', data);
             const assistiveLive = this.$gettextInterpolate(
-                this.$gettext('%{elementTitle}, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.'), 
+                this.$gettext('%{elementTitle}, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.'),
                 {elementTitle: this.element.attributes.title, pos: data.newPos + 1, listLength: this.siblingCount }
             );
             this.setAssistiveLiveContents(assistiveLive);
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..73dc966e3e6
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -0,0 +1,232 @@
+<template>
+    <div class="cw-dashboard-students-wrapper">
+        <div>
+            <TaskGroupListItem
+                v-for="(taskGroup, index) in taskGroups"
+                :taskGroup="taskGroup"
+                :tasks="tasksByGroup[taskGroup.id]"
+                :key="taskGroup.id"
+                :open="index === 0"
+                @add-feedback="onShowAddFeedback"
+                @add-peer-review-process="onShowPeerReviewProcessCreate"
+                @edit-feedback="onShowEditFeedback"
+                @solve-renewal="onShowSolveRenewal"
+            />
+        </div>
+
+        <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="selectedTaskGroup"
+            @create="onCreatePeerReviewProcess"
+            @close="closeDialogs"
+        />
+
+        <RenewalDialog
+            v-if="renewalTask"
+            :renewalDate="renewalDate"
+            :renewalState="renewalTask.attributes.renewal"
+            @update="updateRenewal"
+            @close="closeDialogs"
+        />
+
+        <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" />
+    </div>
+</template>
+
+<script>
+import AddFeedbackDialog from './AddFeedbackDialog.vue';
+import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue';
+import EditFeedbackDialog from './EditFeedbackDialog.vue';
+import RenewalDialog from './RenewalDialog.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import StudipDialog from '../../StudipDialog.vue';
+import CoursewareCompanionBox from '../CoursewareCompanionBox.vue';
+import CoursewareDateInput from '../CoursewareDateInput.vue';
+import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import TaskGroupListItem from './TaskGroupListItem.vue';
+import taskHelperMixin from '../../../mixins/courseware/task-helper.js';
+import { mapActions, mapGetters } from 'vuex';
+import _ from 'lodash';
+
+export default {
+    name: 'courseware-dashboard-students',
+    mixins: [taskHelperMixin],
+    components: {
+        AddFeedbackDialog,
+        PeerReviewProcessCreateDialog,
+        CoursewareCompanionBox,
+        CoursewareDateInput,
+        CoursewareTasksDialogDistribute,
+        EditFeedbackDialog,
+        RenewalDialog,
+        StudipDialog,
+        StudipIcon,
+        TaskGroupListItem,
+    },
+    data() {
+        return {
+            currentDialogFeedback: {},
+            renewalTask: null,
+            selectedTaskGroup: null,
+            showAddFeedbackDialog: false,
+            showPeerReviewProcessCreate: null,
+            showEditFeedbackDialog: false,
+        };
+    },
+
+    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: 'tasks/showTasksDistributeDialog',
+            taskGroupsByCid: 'tasks/taskGroupsByCid',
+            tasksByCid: 'tasks/tasksByCid',
+        }),
+        taskGroups() {
+            return _.sortBy(this.taskGroupsByCid(this.context.id), [
+                (taskGroup) => -new Date(taskGroup.attributes['end-date']),
+            ]);
+        },
+        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;
+            }, {});
+        },
+        tasks() {
+            return this.allTasks.map((task) => {
+                const result = {
+                    task,
+                    taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }),
+                };
+
+                return result;
+            });
+        },
+        managerUrl() {
+            return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', { cid: this.context.id });
+        },
+        renewalDate() {
+            return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date();
+        },
+    },
+    methods: {
+        ...mapActions({
+            companionError: 'companionError',
+            companionSuccess: 'companionSuccess',
+            copyStructuralElement: 'copyStructuralElement',
+            createPeerReviewProcess: 'tasks/createPeerReviewProcess',
+            createTaskFeedback: 'createTaskFeedback',
+            deleteTaskFeedback: 'deleteTaskFeedback',
+            loadAllTasks: 'courseware-tasks/loadAll',
+            loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure',
+            updateTask: 'updateTask',
+            updateTaskFeedback: 'updateTaskFeedback',
+        }),
+        closeDialogs() {
+            this.showAddFeedbackDialog = false;
+            this.showEditFeedbackDialog = false;
+            this.showPeerReviewProcessCreate = false;
+
+            this.currentDialogFeedback = {};
+            this.renewalTask = null;
+            this.selectedTaskGroup = 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.selectedTaskGroup, options }).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(taskGroup) {
+            this.selectedTaskGroup = taskGroup;
+            this.showPeerReviewProcessCreate = true;
+        },
+        onShowSolveRenewal(task) {
+            this.renewalTask = _.cloneDeep(task);
+            this.renewalTask.attributes['renewal-date'] = new Date().toISOString();
+        },
+        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();
+        },
+        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 96%
rename from resources/vue/components/courseware/CoursewareDashboardTasks.vue
rename to resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
index 6535272b4b4..a54d2ca9391 100644
--- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
@@ -79,7 +79,7 @@
             </tbody>
         </table>
         <div v-else>
-            <courseware-companion-box 
+            <courseware-companion-box
                 mood="sad"
                 :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')"
             />
@@ -105,11 +105,11 @@
     </div>
 </template>
 <script>
-import CoursewareCompanionBox from './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 '../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/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/tasks/CoursewareTasksActionWidget.vue
similarity index 83%
rename from resources/vue/components/courseware/CoursewareTasksActionWidget.vue
rename to resources/vue/components/courseware/tasks/CoursewareTasksActionWidget.vue
index e428cbe6e88..e8aaf491ab0 100644
--- a/resources/vue/components/courseware/CoursewareTasksActionWidget.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareTasksActionWidget.vue
@@ -13,7 +13,7 @@
 </template>
 
 <script>
-import SidebarWidget from '../SidebarWidget.vue';
+import SidebarWidget from '../../SidebarWidget.vue';
 
 import { mapActions } from 'vuex';
 
@@ -24,8 +24,8 @@ export default {
     },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
         }),
     }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
similarity index 97%
rename from resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
rename to resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
index 78d6d46d684..4bc31503cda 100644
--- a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
@@ -65,7 +65,7 @@
                 <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" />
                 </label>
                 <label>
                     {{ $gettext('Inhalte ergänzen') }}
@@ -237,9 +237,9 @@
 </template>
 
 <script>
-import CoursewareCompanionBox from './CoursewareCompanionBox.vue';
-import CoursewareStructuralElementSelector from './CoursewareStructuralElementSelector.vue';
-import StudipWizardDialog from './../StudipWizardDialog.vue';
+import CoursewareCompanionBox from '../CoursewareCompanionBox.vue';
+import CoursewareStructuralElementSelector from '../CoursewareStructuralElementSelector.vue';
+import StudipWizardDialog from '../../StudipWizardDialog.vue';
 
 import { mapActions, mapGetters } from 'vuex';
 
@@ -316,7 +316,7 @@ export default {
             ],
             selectedSourceUnit: null,
             taskTitle: '',
-            submissionDate: '',
+            endDate: '',
             solverMayAddBlocks: true,
             selectedTask: null,
             selectedTargetUnit: null,
@@ -487,7 +487,7 @@ export default {
     },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
             loadCourseUnits: 'loadCourseUnits',
             loadUserUnits: 'loadUserUnits',
             loadStructuralElement: 'courseware-structural-elements/loadById',
@@ -515,10 +515,16 @@ export default {
             this.loadStructuralElement({ id: id, options: { include: 'children' } });
         },
         async distributeTask() {
+            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': new Date().toISOString(),
+                    'end-date': endDate.toISOString(),
                     'solver-may-add-blocks': this.solverMayAddBlocks,
                 },
                 relationships: {
@@ -567,7 +573,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;
@@ -643,7 +649,7 @@ export default {
         taskTitle() {
             this.validate();
         },
-        submissionDate() {
+        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..7448bb6f7f8
--- /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 '../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/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue
new file mode 100644
index 00000000000..a3c0c222e4e
--- /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 '../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/TaskGroupListItem.vue b/resources/vue/components/courseware/tasks/TaskGroupListItem.vue
new file mode 100644
index 00000000000..0f7748242a5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupListItem.vue
@@ -0,0 +1,153 @@
+<template>
+    <article class="studip toggle" :class="{ open }">
+        <header>
+            <h1>
+                <a href="#">{{ taskGroup.attributes.title }}</a>
+            </h1>
+            <span> {{ $gettext('Laufzeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> </span>
+        </header>
+
+        <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="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('Laufzeit') }}</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>
+                            <CompanionBox
+                                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>
+    </article>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import CompanionBox from '../CoursewareCompanionBox.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDate from '../../StudipDate.vue';
+import TaskItem from './TaskGroupTaskItem.vue';
+import ProcessListItem from './TaskGroupPeerReviewProcessListItem.vue';
+
+export default {
+    components: { CompanionBox, ProcessListItem, StudipActionMenu, StudipDate, TaskItem },
+    props: ['open', '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,
+            });
+        },
+        peerReviewActionMenuItems() {
+            return [
+                {
+                    emit: 'add-peer-review-process',
+                    icon: 'add',
+                    id: 'add-peer-review-processes',
+                    label: this.$gettext('Peer-Review aktivieren'),
+                },
+            ];
+        },
+        canAddPeerReviewProcess() {
+            return this.startDate < new Date();
+        },
+        endDate() {
+            return new Date(this.taskGroup.attributes['end-date']);
+        },
+        hasPeerReviewProcesses() {
+            return !!this.peerReviewProcesses;
+        },
+        peerReviewProcesses() {
+            return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' });
+        },
+        startDate() {
+            return new Date(this.taskGroup.attributes['start-date']);
+        },
+    },
+};
+</script>
+
+<style>
+article.studip > section > table.default + table.default {
+    margin-block-start: 20px;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
new file mode 100644
index 00000000000..1e2153ba8c5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcessListItem.vue
@@ -0,0 +1,76 @@
+<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>
+            <a :href="url">
+                <StudipDate :date="startDate" />
+                –
+                <StudipDate :date="endDate" />
+            </a>
+        </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);
+        },
+        url(): string {
+            return window.STUDIP.URLHelper.getURL(
+                `dispatch.php/course/courseware/peer_review#peer-review-process-${this.process.id}`,
+                {
+                    cid: this.cid,
+                }
+            );
+        },
+    },
+});
+</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/TasksApp.vue b/resources/vue/components/courseware/tasks/TasksApp.vue
similarity index 100%
rename from resources/vue/components/courseware/TasksApp.vue
rename to resources/vue/components/courseware/tasks/TasksApp.vue
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..06e860ec5b5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
@@ -0,0 +1,86 @@
+<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>
+            Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
+            dolore magna aliqua. Ut enimad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
+            commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
+            nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
+            anim id est laborum.
+            <component v-bind:is="assessmentComponent" :process="process" :review="review"></component>
+            <pre>{{ process }}</pre>
+            <pre>{{ review }}</pre>
+        </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 { 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: () => ({}),
+    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: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm() {},
+    },
+};
+</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..7859568330e
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
@@ -0,0 +1,88 @@
+<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() {
+            console.debug('defaultPayload', this.configuration.type, ASSESSMENT_TYPES);
+            return ASSESSMENT_TYPES[this.configuration.type].defaultPayload ?? {};
+        },
+        payload: {
+            get() {
+                console.debug('payload', this.configuration.payload);
+                return _.isEmpty(this.configuration.payload) ? this.defaultPayload : this.configuration.payload;
+            },
+            set(payload) {
+                this.localPayload = payload;
+            },
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('updateShow', false);
+        },
+        onConfirm(...args) {
+            TODO: console.debug('onConfirm', args, 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..2e78761d9a4
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeForm.vue
@@ -0,0 +1,100 @@
+<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() {
+            this.$emit(
+                'answer',
+                this.criteria.map((_, index) => this.answers[index] ?? '')
+            );
+        },
+    },
+    mounted() {},
+    watch: {},
+});
+</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..a2db10b3857
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeFreetext.vue
@@ -0,0 +1,88 @@
+<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() {
+            this.$emit('answer', this.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..aa80e231f22
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeTable.vue
@@ -0,0 +1,119 @@
+<template>
+<article>
+    <pre>{{ ratings }}</pre>
+        <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]"
+                    @change="changeAnswers" />
+
+                <ul>
+                <li v-for="(text, rating) in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]"
+                    :key="text">
+                    <label>
+                        <input v-model="ratings[index]" type="radio" :value="rating" />
+                        {{ text }}
+                    </label>
+                    </li>
+                </ul>
+                </section>
+            </div>
+        </form>
+    </article>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import { JsonApiSchema } from './definitions';
+import { 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;
+    };
+}
+
+interface PeerReviewSchema {
+    id?: string;
+    type: string;
+    meta?: any;
+    relationships?: any;
+    attributes: {
+        assessment: {
+            answers: string[];
+            ratings: number[];
+        };
+        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 ?? [],
+            ratings: this.review.attributes.assessment.ratings ?? [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = <TableAssessmentPayload>this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            this.$emit(
+                'answer',
+                this.criteria.map((_, index) => this.answers[index] ?? '')
+            );
+        },
+    },
+    mounted() {},
+    watch: {},
+});
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+
+.formpart > section {
+    display: flex;
+}
+
+</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..a7c16b894c3
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorForm.vue
@@ -0,0 +1,141 @@
+<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">
+                    <template #title>Kriterium {{ index + 1 }}</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 '../../CoursewareTab.vue';
+import CoursewareTabs from '../../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..b37f943578c
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/EditorTable.vue
@@ -0,0 +1,150 @@
+<template>
+    <div>
+        <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>
+
+        <footer>
+            <button class="button" type="button" @click="doSave">{{ $gettext('Speichern') }}</button>
+            <button class="button" type="button" @click="doCancel">{{ $gettext('Abbrechen') }}</button>
+        </footer>
+    </div>
+</template>
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipArticle from '../../../StudipArticle.vue';
+import CoursewareTab from '../../CoursewareTab.vue';
+import CoursewareTabs from '../../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: '' });
+        },
+        doCancel() {
+            this.$emit('cancel');
+        },
+        doSave() {
+            this.$emit(
+                'save',
+                this.nonEmptyCriteria.map((c) => ({ ...c }))
+            );
+        },
+        removeLine(lineNumber: number) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text }) => ({ text }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        criteria() {
+            this.resetLocalState();
+        },
+    },
+});
+</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/PeerReviewApp.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewApp.vue
new file mode 100644
index 00000000000..ea16a01d1af
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewApp.vue
@@ -0,0 +1,34 @@
+<template>
+    <div class="cw-peer-review-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/PeerReviewAssignments.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewAssignments.vue
new file mode 100644
index 00000000000..46e111d8972
--- /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>Laufzeit</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..79a1afc9477
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
@@ -0,0 +1,95 @@
+<template>
+    <div>
+        Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore
+        magna aliqua. Ut enimad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+        consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
+        pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est
+        laborum.
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>Aufgabe</th>
+                    <th>submitter</th>
+                    <th>reviewer</th>
+                    <th>assessment</th>
+                </tr>
+            </thead>
+            <tbody v-if="listIsDoneLoading">
+                <PeerReviewListItem
+                    v-for="review in peerReviews"
+                    :review="review"
+                    :key="review.id"
+                    :process="process"
+                    :task-group="taskGroup"
+                />
+            </tbody>
+            <tbody v-if="listIsLoading">
+                <tr>
+                    <td colspan="4">
+                        <ProgressIndicator :description="$gettext('Lade Peer-Reviews…')" />
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</template>
+
+<script lang="ts">
+import Vue, { PropType } from 'vue';
+import { mapActions, mapGetters } from 'vuex';
+import { JsonApiSchema } from './definitions';
+import PeerReviewListItem from './PeerReviewListItem.vue';
+import ProgressIndicator from '../../../StudipProgressIndicator.vue';
+
+enum ListState {
+    Idle = 'idle',
+    Loading = 'loading',
+    Done = 'done',
+}
+
+export default Vue.extend({
+    components: { PeerReviewListItem, ProgressIndicator },
+    props: {
+        process: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+        taskGroup: {
+            type: Object as PropType<JsonApiSchema>,
+            required: true,
+        },
+    },
+    data: () => ({
+        state: ListState.Idle,
+    }),
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+        }),
+        listIsDoneLoading() {
+            return this.state === ListState.Done;
+        },
+        listIsLoading() {
+            return this.state === ListState.Loading;
+        },
+        peerReviews() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+        }),
+    },
+    mounted() {
+        this.state = ListState.Loading;
+        this.loadRelatedPeerReviews({
+            parent: this.process,
+            relationship: 'peer-reviews',
+            options: {
+                include: 'reviewer,task',
+            },
+        }).then(() => (this.state = ListState.Done));
+    },
+});
+</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..2e0e865fcf8
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
@@ -0,0 +1,114 @@
+<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">{{ $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';
+
+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',
+        }),
+        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..eab2ba9a8d1
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
@@ -0,0 +1,248 @@
+<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="customizeConfiguration"
+                        >
+                            <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('Festgelegt') }}</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();
+        },
+        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);
+        },
+    },
+    mounted() {
+        this.resetData();
+    },
+    updated() {
+        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/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
new file mode 100644
index 00000000000..6263596c7d5
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
@@ -0,0 +1,65 @@
+<template>
+    <StudipDialog
+        :title="$gettext('Peer-Review-Prozess konfigurieren')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="confirm"
+    >
+        <template #dialogContent>
+            <header>
+                <h2>
+                    {{ process.attributes.configuration }}
+                    <span>
+                        (<StudipDate :date="new Date(process.attributes['review-start'])" />
+                        –
+                        <StudipDate :date="new Date(process.attributes['review-end'])" />)
+                    </span>
+                </h2>
+            </header>
+
+            <ProcessCreateForm :configuration="process.attributes.configuration" custom @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: ['process'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    methods: {
+        confirm() {
+            this.$emit('update', {
+                process: this.process,
+                configuration: { ...this.configuration },
+            });
+        },
+        updateConfiguration(configuration: ProcessConfiguration) {
+            console.debug(JSON.stringify(configuration));
+            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..0582c2cf077
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
@@ -0,0 +1,137 @@
+<template>
+    <div class="peer-review-processes-list">
+        <ListItem
+            v-for="process in sortedProcesses"
+            :id="`peer-review-process-${process.id}`"
+            :key="process.id"
+            :process="process"
+            :autors="autors"
+            :groups="groups"
+            @showAssessmentTypeEditor="onShowAssessmentTypeEditor"
+            @showConfiguration="onShowConfiguration"
+            />
+        <AssessmentTypeEditorDialog
+            v-model="showAssessmentTypeEditor"
+            :process="selectedProcess"
+            @update="onUpdateAssessmentType"
+            />
+        <ProcessEditDialog
+            v-if="showPeerReviewProcessEdit"
+            :process="selectedProcess"
+            @update="onUpdatePeerReviewProcess"
+            @close="showPeerReviewProcessEdit = false"
+            />
+
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import AssessmentTypeEditorDialog from './AssessmentTypeEditorDialog.vue';
+import ProcessEditDialog from './ProcessEditDialog.vue';
+import ListItem from './ProcessesListItem.vue';
+
+export default {
+    props: {
+    },
+    components: { ListItem, AssessmentTypeEditorDialog, ProcessEditDialog },
+    data: () => ({
+        selectedProcess: null,
+        showAssessmentTypeEditor: false,
+        showPeerReviewProcessEdit: false,
+    }),
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedCourseMemberships: 'course-memberships/related',
+            relatedCourseStatusGroups: 'status-groups/related',
+            relatedUsers: 'users/related',
+            relatedProcesses: 'courseware-peer-review-processes/related',
+            taskGroups: 'courseware-task-groups/all',
+        }),
+        sortedProcesses() {
+            return _.sortBy(this.processes, [({ attributes }) => -new Date(attributes['review-end'])]);
+        },
+        ///
+        autors() {
+            const memberships = this.relatedCourseMemberships({
+                parent: { type: 'courses', id: this.context.id },
+                relationship: 'memberships',
+            });
+
+            return (
+                memberships?.map(({ id, type, attributes }) => {
+                    const member = this.relatedUsers({ parent: { id, type }, relationship: 'user' });
+
+                    return {
+                        user_id: member.id,
+                        formattedname: member.attributes['formatted-name'],
+                        username: member.attributes['username'],
+                        perm: attributes['permission'],
+                    };
+                }) ?? []
+            );
+        },
+        groups() {
+            const parent = { type: 'courses', id: this.context.id };
+            const relationship = 'status-groups';
+            const statusGroups = this.relatedCourseStatusGroups({ parent, relationship });
+
+            return (
+                statusGroups?.map((statusGroup) => {
+                    return {
+                        id: statusGroup.id,
+                        name: statusGroup.attributes['name'],
+                    };
+                }) ?? []
+            );
+        },
+        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',
+            updatePeerReviewProcess: 'tasks/updatePeerReviewProcess',
+        }),
+        onShowAssessmentTypeEditor(process) {
+            this.selectedProcess = process;
+            this.showAssessmentTypeEditor = true;
+        },
+        onShowConfiguration(process) {
+            this.selectedProcess = process;
+            this.showPeerReviewProcessEdit = true;
+        },
+        onUpdateAssessmentType(...args) {
+            console.debug("onUpdateAssessmentType", args);
+        },
+        onUpdatePeerReviewProcess({process, configuration}) {
+            this.updatePeerReviewProcess({ process, configuration }).then(() => (this.showPeerReviewProcessEdit = false));
+        },
+    },
+    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>
+<style scoped>
+.peer-review-processes-list > * + * {
+    margin-block-start: 2rem;
+}
+</style>
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..80894fbfa64
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesListItem.vue
@@ -0,0 +1,194 @@
+<template>
+    <StudipArticle collapsable closed>
+        <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('Laufzeit:') }} <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>{{ $gettext('Einstellungen') }}</h2>
+                    </header>
+                    <div>
+                        <ProcessConfiguration :options="configuration" />
+                    </div>
+                    <div>
+                        <button class="button" @click="onShowConfiguration">{{ $gettext('Einstellungen ändern') }}</button>
+                    </div>
+                    <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>
+                </article>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        {{ $gettext('Peer-Reviews') }}
+                    </template>
+                    <template #body>
+                        <PeerReviewList :process="process" :task-group="taskGroup" />
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        Task Group
+                    </template>
+                    <template #body>
+                        <pre>{{ taskGroup }}</pre>
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        Solvers
+                    </template>
+                    <template #body>
+                        <pre>{{ solvers }}</pre>
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        Autors
+                    </template>
+                    <template #body>
+                        <pre>{{ autors }}</pre>
+                    </template>
+                </StudipArticle>
+
+                <StudipArticle collapsable closed>
+                    <template #title>
+                        Statusgruppen
+                    </template>
+                    <template #body>
+                        <pre>{{ groups }}</pre>
+                    </template>
+                </StudipArticle>
+
+            </section>
+        </template>
+    </StudipArticle>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import StudipArticle from '../../../StudipArticle.vue';
+import StudipDate from '../../../StudipDate.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: { PeerReviewList, ProcessConfiguration, ProcessStatus, StudipArticle, StudipDate },
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+        autors: {
+            type: Array,
+            required: true,
+        },
+        groups: {
+            type: Array,
+            required: true,
+        },
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+        }),
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        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);
+        },
+        resetPairings() {
+            console.debug('taskGroup', this.taskGroup);
+        },
+    },
+};
+</script>
+
+<style>
+.peer-review-process-title {
+    margin-inline-start: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue b/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue
new file mode 100644
index 00000000000..9c7414b326b
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/SidebarActionWidget.vue
@@ -0,0 +1,29 @@
+<template>
+    <sidebar-widget id="courseware-action-widget" :title="$gettext('Aktionen')">
+        <template #content>
+            <ul class="widget-list widget-links cw-action-widget">
+                <li class="cw-action-widget-add">
+                    <button @click="foo(true)">
+                        {{ $gettext('Foo') }}
+                    </button>
+                </li>
+            </ul>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../../../SidebarWidget.vue';
+
+import { mapActions } from 'vuex';
+
+export default {
+    components: {
+        SidebarWidget,
+    },
+    methods: {
+        ...mapActions({}),
+        foo() {},
+    },
+};
+</script>
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/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 a31686bceb4..c3c798ca6a0 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -97,6 +97,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-peer-review-app.js b/resources/vue/courseware-peer-review-app.js
new file mode 100644
index 00000000000..c5e4684f579
--- /dev/null
+++ b/resources/vue/courseware-peer-review-app.js
@@ -0,0 +1,94 @@
+import axios from 'axios';
+import Vuex from 'vuex';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import App from './components/courseware/tasks/peer-review/PeerReviewApp.vue';
+import tasks from './store/courseware/courseware-tasks.module';
+import courseware from './store/courseware/courseware.module';
+import coursewareStructure from './store/courseware/structure.module';
+
+const mountApp = async (STUDIP, createApp, element) => {
+    const getHttpClient = () =>
+        axios.create({
+            baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true),
+            headers: {
+                'Content-Type': 'application/vnd.api+json',
+            },
+        });
+
+    const httpClient = getHttpClient();
+
+    const store = new Vuex.Store({
+        modules: {
+            courseware,
+            tasks,
+            'courseware-structure': coursewareStructure,
+            ...mapResourceModules({
+                names: [
+                    'activities',
+                    'users',
+                    'courses',
+                    'course-memberships',
+                    'courseware-blocks',
+                    'courseware-block-comments',
+                    'courseware-block-feedback',
+                    'courseware-containers',
+                    'courseware-instances',
+                    'courseware-peer-reviews',
+                    'courseware-peer-review-processes',
+                    'courseware-structural-elements',
+                    'courseware-task-feedback',
+                    'courseware-task-groups',
+                    'courseware-tasks',
+                    'courseware-units',
+                    'courseware-user-data-fields',
+                    'courseware-user-progresses',
+                    'files',
+                    'file-refs',
+                    'folders',
+                    'users',
+                    'institutes',
+                    'semesters',
+                    'sem-classes',
+                    'sem-types',
+                    'status-groups',
+                ],
+                httpClient,
+            }),
+        },
+    });
+    let entry_id = null;
+    let entry_type = null;
+    let elem;
+
+    if ((elem = document.getElementById(element.substring(1))) !== undefined) {
+        if (elem.attributes !== undefined) {
+            if (elem.attributes['entry-type'] !== undefined) {
+                entry_type = elem.attributes['entry-type'].value;
+            }
+
+            if (elem.attributes['entry-id'] !== undefined) {
+                entry_id = elem.attributes['entry-id'].value;
+            }
+        }
+    }
+
+    store.dispatch('setUserId', 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);
+
+    const app = createApp({
+        render: (h) => h(App),
+        store,
+    });
+
+    app.$mount(element);
+
+    return app;
+};
+
+export default mountApp;
diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js
index 2f332466d79..fe929e4d1cc 100644
--- a/resources/vue/courseware-tasks-app.js
+++ b/resources/vue/courseware-tasks-app.js
@@ -1,4 +1,4 @@
-import TasksApp from './components/courseware/TasksApp.vue';
+import TasksApp from './components/courseware/tasks/TasksApp.vue';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
 import Vuex from 'vuex';
 import CoursewareModule from './store/courseware/courseware.module';
@@ -33,6 +33,7 @@ const mountApp = async (STUDIP, createApp, element) => {
                     'courseware-block-feedback',
                     'courseware-containers',
                     'courseware-instances',
+                    'courseware-peer-review-processes',
                     'courseware-structural-elements',
                     'courseware-task-feedback',
                     'courseware-task-groups',
@@ -78,12 +79,7 @@ const mountApp = async (STUDIP, createApp, element) => {
         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',
-        },
-    });
+    store.dispatch('tasks/loadTasksOfCourse', { cid: entry_id });
 
     const app = createApp({
         render: (h) => h(TasksApp),
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..ed0c96d78e4 100644
--- a/resources/vue/store/courseware/courseware-tasks.module.js
+++ b/resources/vue/store/courseware/courseware-tasks.module.js
@@ -1,3 +1,5 @@
+import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration';
+
 const getDefaultState = () => {
     return {
         showTasksDistributeDialog: false,
@@ -10,6 +12,22 @@ const getters = {
     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 };
@@ -21,15 +39,66 @@ export const actions = {
     },
 
     // 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 });
+    },
+
+    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 });
+    },
+    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];
+        }
+
+        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 });
+    },
 };
 
 export const mutations = {
-    setShowTasksDistributeDialog(state, data){
+    setShowTasksDistributeDialog(state, data) {
         state.showTasksDistributeDialog = 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 0f56e3e7af8..98ff93ad70c 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1264,7 +1264,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"]
-- 
GitLab