From c3212318a90df065d3f7b0c78bc2152ebdc69906 Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>
Date: Tue, 14 Nov 2023 11:57:16 +0100
Subject: [PATCH] Add Peer Review on top of feature/better-tasks.

---
 app/controllers/course/courseware.php         |  12 +-
 .../6.0.38_add_peer_review_tables.php         |  56 +++
 lib/classes/JsonApi/RouteMap.php              |  17 +
 .../JsonApi/Routes/Courseware/Authority.php   | 128 ++++++-
 .../Courseware/PeerReview/ProcessesCreate.php | 124 ++++++
 .../Courseware/PeerReview/ProcessesDelete.php |  38 ++
 .../Courseware/PeerReview/ProcessesIndex.php  | 108 ++++++
 .../Courseware/PeerReview/ProcessesShow.php   |  47 +++
 .../Courseware/PeerReview/ProcessesUpdate.php | 121 ++++++
 .../PeerReview/ReviewsByTaskIndex.php         |  76 ++++
 .../Courseware/PeerReview/ReviewsCreate.php   | 180 +++++++++
 .../Courseware/PeerReview/ReviewsDelete.php   |  38 ++
 .../Courseware/PeerReview/ReviewsIndex.php    |  77 ++++
 .../PeerReview/ReviewsOfProcessesIndex.php    |  74 ++++
 .../Courseware/PeerReview/ReviewsShow.php     |  50 +++
 .../Courseware/PeerReview/ReviewsUpdate.php   |  78 ++++
 .../Routes/Courseware/TaskGroupsShow.php      |   1 +
 .../JsonApi/Routes/Courseware/TasksIndex.php  |   1 +
 .../JsonApi/Routes/Courseware/TasksShow.php   |   4 +
 lib/classes/JsonApi/SchemaMap.php             |   2 +
 .../JsonApi/Schemas/Courseware/PeerReview.php | 101 +++++
 .../Schemas/Courseware/PeerReviewProcess.php  |  77 ++++
 .../JsonApi/Schemas/Courseware/Task.php       |  58 ++-
 .../JsonApi/Schemas/Courseware/TaskGroup.php  |  31 +-
 lib/models/Courseware/PeerReview.php          |  93 +++++
 lib/models/Courseware/PeerReviewProcess.php   | 188 ++++++++++
 lib/models/Courseware/StructuralElement.php   |   4 +-
 lib/models/Courseware/Task.php                |  75 +++-
 lib/models/Courseware/TaskGroup.php           |  31 ++
 resources/assets/stylesheets/scss/wizard.scss |   2 +-
 .../vue/components/ConsultationCreator.vue    |   4 +-
 resources/vue/components/StudipActionMenu.vue |   2 +
 resources/vue/components/StudipArticle.vue    |  62 +++
 resources/vue/components/StudipContentBox.vue |  46 +++
 resources/vue/components/StudipUserAvatar.vue |  38 ++
 .../blocks/CoursewareTableOfContentsBlock.vue |  24 +-
 .../CoursewareStructuralElement.vue           | 136 ++++++-
 .../structural-element/CoursewareTreeItem.vue |  13 +-
 .../tasks/CoursewareDashboardStudents.vue     |  37 +-
 .../tasks/CoursewareDashboardTasks.vue        | 355 +-----------------
 .../tasks/CoursewareDashboardTasksList.vue    | 351 +++++++++++++++++
 .../tasks/CoursewareTasksDialogDistribute.vue |  10 +-
 .../courseware/tasks/PagesTaskGroupsShow.vue  |  43 ++-
 .../courseware/tasks/RenewalDialog.vue        |   5 +-
 .../components/courseware/tasks/TaskGroup.vue |  19 +-
 .../tasks/TaskGroupPeerReviewProcesses.vue    | 158 ++++++++
 .../tasks/TaskGroupsModifyDeadlineDialog.vue  |   5 +-
 .../tasks/peer-review/AssessmentDialog.vue    | 115 ++++++
 .../peer-review/AssessmentTypeEditor.vue      |  65 ++++
 .../AssessmentTypeEditorDialog.vue            |  84 +++++
 .../tasks/peer-review/PairingEditor.vue       | 200 ++++++++++
 .../tasks/peer-review/PairingEditorDialog.vue | 102 +++++
 .../tasks/peer-review/PeerReviewList.vue      |  66 ++++
 .../tasks/peer-review/PeerReviewListItem.vue  | 134 +++++++
 .../peer-review/ProcessConfiguration.vue      |  39 ++
 .../tasks/peer-review/ProcessCreateDialog.vue | 131 +++++++
 .../tasks/peer-review/ProcessCreateForm.vue   | 319 ++++++++++++++++
 .../tasks/peer-review/ProcessDetail.vue       | 217 +++++++++++
 .../peer-review/ProcessDurationDialog.vue     | 116 ++++++
 .../tasks/peer-review/ProcessEditDialog.vue   |  64 ++++
 .../tasks/peer-review/ProcessStatus.vue       |  47 +++
 .../tasks/peer-review/ProcessesList.vue       | 174 +++++++++
 .../tasks/peer-review/ResultDialog.vue        |  71 ++++
 .../assessment-types/editors/EditorForm.vue   | 149 ++++++++
 .../assessment-types/editors/EditorTable.vue  | 138 +++++++
 .../forms/AssessmentTypeForm.vue              |  70 ++++
 .../forms/AssessmentTypeFreetext.vue          |  64 ++++
 .../forms/AssessmentTypeTable.vue             | 114 ++++++
 .../assessment-types/results/Form.vue         |  54 +++
 .../assessment-types/results/Freetext.vue     |  42 +++
 .../assessment-types/results/Table.vue        |  71 ++++
 .../tasks/peer-review/definitions.ts          |  57 +++
 .../peer-review/process-configuration.ts      | 129 +++++++
 .../widgets/CoursewareTasksActionWidget.vue   |   8 +-
 .../vue/components/forms/LabelRequired.vue    |  22 ++
 .../courseware/courseware-tasks.module.js     |  92 ++++-
 .../vue/store/courseware/courseware.module.js |   2 +-
 webpack.common.js                             |   3 +-
 78 files changed, 5722 insertions(+), 437 deletions(-)
 create mode 100644 db/migrations/6.0.38_add_peer_review_tables.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.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/vue/components/StudipArticle.vue
 create mode 100644 resources/vue/components/StudipContentBox.vue
 create mode 100644 resources/vue/components/StudipUserAvatar.vue
 create mode 100644 resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/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/ProcessDetail.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue
 create mode 100644 resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.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

diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index 8ecff0a9e30..ef7c38d205a 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -98,8 +98,16 @@ class Course_CoursewareController extends CoursewareController
             Context::getId(),
             $GLOBALS['user']->id
         );
-        Navigation::activateItem('course/courseware/tasks');
-        PageLayout::setTitle(_('Courseware: Aufgaben'));
+        switch ($route) {
+            case 'peer-review-processes':
+                Navigation::activateItem('course/courseware/peer-review');
+                PageLayout::setTitle(_('Courseware: Peer-Review-Prozesse'));
+                break;
+            default:
+                Navigation::activateItem('course/courseware/tasks');
+                PageLayout::setTitle(_('Courseware: Aufgaben'));
+                break;
+        }
         $this->setTasksSidebar();
     }
 
diff --git a/db/migrations/6.0.38_add_peer_review_tables.php b/db/migrations/6.0.38_add_peer_review_tables.php
new file mode 100644
index 00000000000..893d59a6007
--- /dev/null
+++ b/db/migrations/6.0.38_add_peer_review_tables.php
@@ -0,0 +1,56 @@
+<?php
+class AddPeerReviewTables extends Migration
+{
+    public function description()
+    {
+        return "Adds the Courseware peer review tables.";
+    }
+
+    public function up()
+    {
+        $db = \DBManager::get();
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_review_processes`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `task_group_id` INT(11) NOT NULL,
+                 `owner_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `configuration` MEDIUMTEXT NOT NULL,
+                 `review_start` INT(11) UNSIGNED NOT NULL,
+                 `review_end` INT(11) UNSIGNED NOT NULL,
+                 `paired_at` INT(11) UNSIGNED NULL,
+                 `mkdate` INT(11) UNSIGNED NOT NULL,
+                 `chdate` INT(11) UNSIGNED NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_task_group_id(`task_group_id`),
+                 INDEX index_owner_id(`owner_id`)
+             )"
+        );
+
+        $db->exec(
+            "CREATE TABLE `cw_peer_reviews`(
+                 `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                 `process_id` INT(11) UNSIGNED NOT NULL,
+                 `task_id` INT(11) UNSIGNED NOT NULL,
+                 `submitter_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+                 `reviewer_type` ENUM('autor', 'group') COLLATE latin1_bin,
+                 `assessment` TEXT,
+                 `mkdate` INT(11) UNSIGNED NOT NULL,
+                 `chdate` INT(11) UNSIGNED NOT NULL,
+                 PRIMARY KEY(`id`),
+                 INDEX index_process_id(`process_id`),
+                 INDEX index_task_id(`task_id`),
+                 INDEX index_submitter_id(`submitter_id`),
+                 INDEX index_reviewer_id(`reviewer_id`)
+             )"
+        );
+    }
+
+    public function down()
+    {
+        $db = \DBManager::get();
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_reviews`');
+        $db->exec('DROP TABLE IF EXISTS `cw_peer_review_processes`');
+    }
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 3870d9a2055..6b2f429314c 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -594,6 +594,23 @@ class RouteMap
         $group->delete('/courseware-clipboards/{id}', Routes\Courseware\ClipboardsDelete::class);
 
         $group->post('/courseware-clipboards/{id}/insert', Routes\Courseware\ClipboardsInsert::class);
+
+        $group->get('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesIndex::class);
+        $group->get('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesShow::class);
+        $group->get('/courseware-peer-review-processes/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsOfProcessesIndex::class);
+
+        $group->patch('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesUpdate::class);
+        $group->delete('/courseware-peer-review-processes/{id}', Routes\Courseware\PeerReview\ProcessesDelete::class);
+
+        $group->post('/courseware-peer-review-processes', Routes\Courseware\PeerReview\ProcessesCreate::class);
+
+        $group->get('/courses/{id}/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsIndex::class);
+        $group->get('/courseware-tasks/{id}/peer-reviews', Routes\Courseware\PeerReview\ReviewsByTaskIndex::class);
+
+        $group->get('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsShow::class);
+        $group->post('/courseware-peer-reviews', Routes\Courseware\PeerReview\ReviewsCreate::class);
+        $group->patch('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsUpdate::class);
+        $group->delete('/courseware-peer-reviews/{id}', Routes\Courseware\PeerReview\ReviewsDelete::class);
     }
 
     private function addAuthenticatedFilesRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index 7ed609fc65d..87bda5e8480 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;
@@ -324,12 +326,31 @@ class Authority
 
     public static function canShowTask(User $user, Task $resource): bool
     {
-        return self::canUpdateTask($user, $resource) || $resource->visible;
+        // TODO (mel): Das beißt sich hier ein wenig und muß mit Nico besprochen werden. Peer Review vs. visible
+        return ($resource->isPeerReviewed() && $resource->isPeerReviewedBy($user))
+            || self::canUpdateTask($user, $resource)
+            || $resource->visible;
+    }
+
+    public static function canShowTaskSolver(User $user, Task $resource): bool
+    {
+        if (self::canUpdateTask($user, $resource)) {
+            return true;
+        }
+
+        if ($resource->userIsAPeerReviewer($user)) {
+            return array_reduce(
+                $resource->getPeerReviewProcessessWithReviewsBy($user),
+                fn($memo, $process) => $memo || !$process->isAnonymous(),
+                false
+            );
+        }
+
+        return false;
     }
 
     public static function canIndexTasks(User $user): bool
     {
-        // TODO: filtered index permissions are handled in the route
         return $GLOBALS['perm']->have_perm('root', $user->id);
     }
 
@@ -584,4 +605,107 @@ class Authority
         return $resource->user_id === $user->id;
     }
 
+    public static function canIndexPeerReviewProcesses(User $user): bool
+    {
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('user', $process->task_group['seminar_id'], $user->getId());
+    }
+
+    public static function canCreatePeerReviewProcesses(User $user, TaskGroup $taskGroup): bool
+    {
+        return $GLOBALS['perm']->have_studip_perm('tutor', $taskGroup['seminar_id'], $user->getId());
+    }
+
+    public static function canUpdatePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReviewProcess(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canIndexPeerReviews(User $user)
+    {
+        return (bool) $user;
+    }
+
+    public static function canShowPeerReview(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        return $review->isReviewer($user) || $review->isSubmitter($user);
+    }
+
+    public static function canShowPeerReviewReviewer(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        if ($review->isReviewer($user)) {
+            return true;
+        }
+
+        return $review->isSubmitter($user) && !$review->isAnonymous();
+    }
+
+    public static function canShowPeerReviewSubmitter(User $user, PeerReview $review): bool
+    {
+        $cid = $review->process->task_group['seminar_id'];
+        if ($GLOBALS['perm']->have_studip_perm('tutor', $cid, $user->getId())) {
+            return true;
+        }
+
+        if ($review->isSubmitter($user)) {
+            return true;
+        }
+
+        return $review->isReviewer($user) && !$review->isAnonymous();
+    }
+
+    public static function canShowPeerReviewAssessment(User $user, PeerReview $review): bool
+    {
+        if ($review->isReviewer($user)) {
+            return true;
+        }
+
+        $isTutor = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            $review->process->task_group['seminar_id'],
+            $user->getId()
+        );
+
+        return ($isTutor || $review->isSubmitter($user))
+            && $review->process->getCurrentState() === PeerReviewProcess::STATE_AFTER;
+    }
+
+    public static function canIndexReviewsOfProcesses(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canShowPeerReviewProcess($user, $process);
+    }
+
+    public static function canUpdatePeerReview(User $user, PeerReview $review): bool
+    {
+        return $review->process->getCurrentState() === PeerReviewProcess::STATE_ACTIVE && $review->isReviewer($user);
+    }
+
+    public static function canCreatePeerReviews(User $user, PeerReviewProcess $process): bool
+    {
+        return self::canCreatePeerReviewProcesses($user, $process->task_group);
+    }
+
+    public static function canDeletePeerReview(User $user, PeerReview $review): bool
+    {
+        return self::canCreatePeerReviews($user, $review->process);
+    }
 }
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
new file mode 100644
index 00000000000..4f8099b7070
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesCreate.php
@@ -0,0 +1,124 @@
+<?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.';
+        }
+
+        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');
+
+        $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..fc19e8b4bff
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+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)
+    {
+        $resource = PeerReviewProcess::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..d45bc23cf24
--- /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): array
+    {
+        return PeerReviewProcess::findByUser($user);
+    }
+
+    private function filterProcesses(User $user, array $filtering): array
+    {
+        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..7579fcd15ac
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesShow.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+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;
+
+/**
+ * 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..d5b6fb55135
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ProcessesUpdate.php
@@ -0,0 +1,121 @@
+<?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\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one PeerReviewProcess.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ProcessesUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\PeerReviewProcess $resource */
+        $resource = PeerReviewProcess::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReviewProcess($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->update($user, $resource, $json);
+
+        return $this->getContentResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewProcessSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.configuration')) {
+            return 'Missing `configuration` attribute.';
+        }
+
+        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..d03deb3f81a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsByTaskIndex.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\Task;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+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)
+    {
+        $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): \SimpleCollection
+    {
+        return $task->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
new file mode 100644
index 00000000000..414f2b441e1
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsCreate.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use Courseware\PeerReviewProcess;
+use InvalidArgumentException;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\PeerReviewProcess as PeerReviewProcessSchema;
+use JsonApi\Schemas\StatusGroup as StatusGroupSchema;
+use JsonApi\Schemas\User as UserSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Statusgruppen;
+use User;
+
+/**
+ * Create a PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     *
+     * @param array $args
+     *
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $process = $this->getProcessFromJson($json);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreatePeerReviews($user, $process)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $resource = $this->create($json);
+
+        return $this->getCreatedResponse($resource);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+        if (self::arrayHas($json, 'data.id')) {
+            return 'New document must not have an `id`.';
+        }
+
+        // process
+        if (!self::arrayHas($json, 'data.relationships.process')) {
+            return 'Missing `process` relationship.';
+        }
+        if (!$this->getProcessFromJson($json)) {
+            return 'Invalid `process` relationship.';
+        }
+
+        // submitter
+        if (!self::arrayHas($json, 'data.relationships.submitter')) {
+            return 'Missing `submitter` relationship.';
+        }
+        if (!$this->getSubmitterFromJson($json)) {
+            return 'Invalid `submitter` relationship.';
+        }
+
+        // reviewer
+        if (!self::arrayHas($json, 'data.relationships.reviewer')) {
+            return 'Missing `reviewer` relationship.';
+        }
+        if (!$this->getReviewerFromJson($json)) {
+            return 'Invalid `reviewer` relationship.';
+        }
+    }
+
+    private function create(array $json): PeerReview
+    {
+        $process = $this->getProcessFromJson($json);
+        $reviewer = $this->getReviewerFromJson($json);
+        $submitter = $this->getSubmitterFromJson($json);
+
+        $task = $process['task_group']->findTaskBySolver($submitter);
+        $reviewerType = $this->getReviewerType($reviewer);
+
+        $review = PeerReview::create([
+            'process_id' => $process->id,
+            'task_id' => $task->id,
+            'submitter_id' => $submitter->id,
+            'reviewer_id' => $reviewer->id,
+            'reviewer_type' => $reviewerType,
+        ]);
+
+        return $review;
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getActorFromJson(array $json, string $relation)
+    {
+        $relationship = 'data.relationships.' . $relation;
+        if (
+            !(
+                $this->validateResourceObject($json, $relationship, UserSchema::TYPE)
+                || $this->validateResourceObject($json, $relationship, StatusGroupSchema::TYPE)
+            )
+        ) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, $relationship . '.data.id');
+
+        switch (self::arrayGet($json, $relationship . '.data.type')) {
+            case UserSchema::TYPE:
+                return User::find($resourceId);
+            case StatusGroupSchema::TYPE:
+                return Statusgruppen::find($resourceId);
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    private function getProcessFromJson(array $json): ?PeerReviewProcess
+    {
+        if (!$this->validateResourceObject($json, 'data.relationships.process', PeerReviewProcessSchema::TYPE)) {
+            return null;
+        }
+        $resourceId = self::arrayGet($json, 'data.relationships.process.data.id');
+
+        return PeerReviewProcess::find($resourceId);
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getReviewerFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'reviewer');
+    }
+
+    private function getReviewerType($reviewer): string
+    {
+        if ($reviewer instanceof User) {
+            return 'autor';
+        }
+        if ($reviewer instanceof Statusgruppen) {
+            return 'group';
+        }
+
+        throw new InvalidArgumentException();
+    }
+
+    /**
+     * @return User|Statusgruppen|null
+     */
+    private function getSubmitterFromJson(array $json)
+    {
+        return $this->getActorFromJson($json, 'submitter');
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
new file mode 100644
index 00000000000..bf0a6c654d6
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one PeerPreview.
+ */
+class ReviewsDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeletePeerReview($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
new file mode 100644
index 00000000000..92d77ced547
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsIndex.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Course;
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+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)
+    {
+        $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): array
+    {
+        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..c67e1a5b10d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsOfProcessesIndex.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReviewProcess;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+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): \SimpleCollection
+    {
+        return $process->peer_reviews->filter(function ($peerReview) use ($user) {
+            return Authority::canShowPeerReview($user, $peerReview);
+        });
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php
new file mode 100644
index 00000000000..83a6cb0f562
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsShow.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use JsonApi\Schemas\Courseware\Task as TaskSchema;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays one PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsShow 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,
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowPeerReview($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
new file mode 100644
index 00000000000..65a21087508
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/PeerReview/ReviewsUpdate.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\PeerReview;
+
+use Courseware\PeerReview;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Updates one PeerReview.
+ *
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class ReviewsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $resource = PeerReview::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdatePeerReview($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $review = $this->update($resource, $json);
+
+        return $this->getContentResponse($review);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (PeerReviewSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.assessment')) {
+            return 'Missing `assessment` attribute.';
+        }
+    }
+
+    private function update(PeerReview $review, array $json): PeerReview
+    {
+        $review->assessment = self::arrayGet($json, 'data.attributes.assessment');
+        $review->store();
+
+        return $review;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/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 26a021c9682..995243741ff 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
@@ -25,6 +25,7 @@ class TasksIndex extends JsonApiController
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
index 619e7eab9e7..419f9509505 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksShow.php
@@ -5,6 +5,7 @@ namespace JsonApi\Routes\Courseware;
 use Courseware\Task;
 use JsonApi\Errors\AuthorizationFailedException;
 use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Schemas\Courseware\PeerReview as PeerReviewSchema;
 use JsonApi\Schemas\Courseware\Task as TaskSchema;
 use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
 use JsonApi\JsonApiController;
@@ -18,10 +19,13 @@ class TasksShow extends JsonApiController
 {
     protected $allowedIncludePaths = [
         TaskSchema::REL_FEEDBACK,
+        TaskSchema::REL_PEER_REVIEWS,
+        TaskSchema::REL_PEER_REVIEWS . '.' . PeerReviewSchema::REL_PROCESS,
         TaskSchema::REL_SOLVER,
         TaskSchema::REL_STRUCTURAL_ELEMENT,
         TaskSchema::REL_TASK_GROUP,
         TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_LECTURER,
+        TaskSchema::REL_TASK_GROUP . '.' . TaskGroupSchema::REL_PEER_REVIEW_PROCESSES,
     ];
 
     /**
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 801bf293831..b3c3179c827 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -81,6 +81,8 @@ 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,
diff --git a/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
new file mode 100644
index 00000000000..2096d324af2
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Courseware/PeerReview.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace JsonApi\Schemas\Courseware;
+
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class PeerReview extends SchemaProvider
+{
+    public const TYPE = 'courseware-peer-reviews';
+
+    public const REL_PROCESS = 'process';
+    public const REL_REVIEWER = 'reviewer';
+    public const REL_SUBMITTER = 'submitter';
+    public const REL_TASK = 'task';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $user = $this->currentUser;
+        $assessment = null;
+        if ($resource->assessment && Authority::canShowPeerReviewAssessment($user, $resource)) {
+            $assessment = $resource->assessment->getIterator();
+        }
+        return [
+            'assessment' => $assessment,
+            'is-reviewer' => $resource->isReviewer($user),
+            'is-submitter' => $resource->isSubmitter($user),
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships[self::REL_PROCESS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($resource->process),
+            ],
+            self::RELATIONSHIP_DATA => $resource->process,
+        ];
+
+        $user = $this->currentUser;
+
+        if (Authority::canShowPeerReviewReviewer($user, $resource)) {
+            $reviewer = $resource->getReviewer();
+            $relationships[self::REL_REVIEWER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($reviewer),
+                ],
+                self::RELATIONSHIP_DATA => $reviewer,
+            ];
+        } else {
+            $relationships[self::REL_REVIEWER] = [
+                self::RELATIONSHIP_DATA => null,
+            ];
+        }
+
+        if (Authority::canShowPeerReviewSubmitter($user, $resource)) {
+            $submitter = $resource->getSubmitter();
+            $relationships[self::REL_SUBMITTER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($submitter),
+                ],
+                self::RELATIONSHIP_DATA => $submitter,
+            ];
+        } else {
+            $relationships[self::REL_SUBMITTER] = [
+                self::RELATIONSHIP_DATA => null,
+            ];
+        }
+
+        $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 c612333886c..1793e62a06c 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/Task.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php
@@ -8,11 +8,15 @@ use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Link;
 
+/**
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
 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';
@@ -30,6 +34,8 @@ 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']),
@@ -37,6 +43,8 @@ class Task extends SchemaProvider
             'renewal' => empty($resource['renewal']) ? null : (string) $resource['renewal'],
             'renewal-date' => date('c', $resource['renewal_date']),
             'visible' => (bool) $resource['visible'],
+            'can-peer-review' => $resource->userIsAPeerReviewer($user),
+            'can-solve' => $resource->userIsASolver($user),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
@@ -59,15 +67,28 @@ class Task extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
-        $solver = $resource->getSolver();
-        $relationships[self::REL_SOLVER] = $solver
-            ? [
-                self::RELATIONSHIP_LINKS => [
-                    Link::RELATED => $this->createLinkToResource($solver),
-                ],
-                self::RELATIONSHIP_DATA => $solver,
-            ]
-            : [self::RELATIONSHIP_DATA => null];
+        $relationships = $this->addPeerReviews(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_PEER_REVIEWS)
+        );
+
+        $user = $this->currentUser;
+
+        if (CoursewareAuthority::canShowTaskSolver($user, $resource)) {
+            $relationships[self::REL_SOLVER] = $resource['solver_id']
+                ? [
+                    self::RELATIONSHIP_LINKS => [
+                        Link::RELATED => $this->createLinkToResource($resource->solver),
+                    ],
+                    self::RELATIONSHIP_DATA => $resource->solver,
+                ]
+                : [self::RELATIONSHIP_DATA => null];
+        } else {
+            $relationships[self::REL_SOLVER] = [
+                self::RELATIONSHIP_DATA => null,
+            ];
+        }
 
         $relationships[self::REL_STRUCTURAL_ELEMENT] = $resource['structural_element_id']
             ? [
@@ -87,4 +108,23 @@ class Task extends SchemaProvider
 
         return $relationships;
     }
+
+    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) {
+            $relationships[self::REL_PEER_REVIEWS][self::RELATIONSHIP_DATA] = $resource->isPeerReviewed()
+                ? $resource->peer_reviews->filter(
+                    fn($review) => CoursewareAuthority::canShowPeerReview($this->currentUser, $review)
+                )
+                : [];
+        }
+
+        return $relationships;
+    }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
index c950671ea47..97d7628ef91 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
@@ -4,17 +4,22 @@ namespace JsonApi\Schemas\Courseware;
 
 use Courseware\StructuralElement;
 use Courseware\TaskGroup as TaskGroupModel;
+use JsonApi\Routes\Courseware\Authority as CoursewareAuthority;
 use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Identifier;
 use Neomerx\JsonApi\Schema\Link;
 
+/**
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
 class TaskGroup extends SchemaProvider
 {
     const TYPE = 'courseware-task-groups';
 
     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';
@@ -68,8 +73,14 @@ class TaskGroup extends SchemaProvider
             ]
             : [self::RELATIONSHIP_DATA => null];
 
+        $relationships = $this->addPeerReviewProcessesRelationship($relationships, $resource, $context);
+
+        $user = $this->currentUser;
         $relationships[self::REL_SOLVERS] = [
-            self::RELATIONSHIP_DATA => $resource->getSolvers(),
+            self::RELATIONSHIP_DATA =>
+                $resource->tasks->filter(
+                    fn($task) => CoursewareAuthority::canShowTaskSolver($user, $task)
+                )->map(fn ($task) => $task->solver),
         ];
 
         $target = StructuralElement::build(['id' => $resource['target_id']]);
@@ -104,4 +115,22 @@ 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..0a62527858a
--- /dev/null
+++ b/lib/models/Courseware/PeerReview.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use Statusgruppen;
+use User;
+
+/**
+ * Courseware's peer review instances.
+ *
+ * @since   Stud.IP 6.0
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+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
+    {
+        $collections = [];
+        foreach (PeerReviewProcess::findByCourse($course) as $process) {
+            $collections[] = $process->getPeerReviews()->getArrayCopy();
+        }
+
+        return array_flatten($collections);
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->process->getCourse();
+    }
+
+    public function isAnonymous(): bool
+    {
+        return $this->process->isAnonymous();
+    }
+
+    public function isReviewer(User $user): bool
+    {
+        return match($this->reviewer_type) {
+            'autor' => $this->reviewer_id === $user->id,
+            'group' => \Statusgruppen::isMemberOf($this->reviewer_id, $user->getId()),
+        };
+    }
+
+    public function getReviewer(): User|Statusgruppen
+    {
+        return match($this->reviewer_type) {
+            'autor' => User::find($this->reviewer_id),
+            'group' => Statusgruppen::find($this->reviewer_id),
+        };
+    }
+
+    public function isSubmitter(User $user): bool
+    {
+        return match (get_class($this->getSubmitter())) {
+            Statusgruppen::class => \Statusgruppen::isMemberOf($this->submitter_id, $user->id),
+            User::class => $this->submitter_id === $user->id
+        };
+    }
+
+    public function getSubmitter(): User|Statusgruppen
+    {
+        return User::find($this->submitter_id)
+            ?? Statusgruppen::find($this->submitter_id);
+    }
+}
diff --git a/lib/models/Courseware/PeerReviewProcess.php b/lib/models/Courseware/PeerReviewProcess.php
new file mode 100644
index 00000000000..ae92698af55
--- /dev/null
+++ b/lib/models/Courseware/PeerReviewProcess.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Courseware;
+
+use Course;
+use DBManager;
+use SimpleORMapCollection;
+use User;
+
+/**
+ * A PeerReviewProcess groups a set of PeerReviews.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ *
+ * @since   Stud.IP 6.0
+ */
+class PeerReviewProcess extends \SimpleORMap
+{
+    public const DEFAULT_DURATION = 7;
+
+    public const STATE_BEFORE = 'before';
+    public const STATE_ACTIVE = 'active';
+    public const STATE_AFTER = 'after';
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'cw_peer_review_processes';
+
+        $config['serialized_fields']['configuration'] = 'JSONArrayObject';
+
+        $config['belongs_to']['task_group'] = [
+            'class_name' => TaskGroup::class,
+            'foreign_key' => 'task_group_id',
+        ];
+        $config['belongs_to']['owner'] = [
+            'class_name' => User::class,
+            'foreign_key' => 'owner_id',
+        ];
+
+        $config['additional_fields']['peer_reviews'] = [
+            'get' => 'getPeerReviews',
+            'set' => false,
+        ];
+
+        $config['has_many']['_peer_reviews'] = [
+            'class_name' => PeerReview::class,
+            'assoc_foreign_key' => 'process_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        parent::configure($config);
+    }
+
+    public static function findByCourse(Course $course): iterable
+    {
+        return self::findBySQL('task_group_id IN (?) ORDER BY mkdate', [
+            DBManager::get()->fetchFirst('SELECT id FROM `cw_task_groups` WHERE seminar_id = ?', [$course->getId()]),
+        ]);
+    }
+
+    public static function findByUser(User $user): iterable
+    {
+        return self::findMany(
+            DBManager::get()->fetchFirst(
+                'SELECT id FROM cw_peer_review_processes
+                   WHERE task_group_id IN (
+                     SELECT id FROM cw_task_groups
+                       WHERE cw_task_groups.seminar_id IN (
+                         SELECT seminar_id FROM seminar_user WHERE user_id = ?))',
+                [$user->getId()]
+            )
+        );
+    }
+
+    public function getCourse(): Course
+    {
+        return $this->task_group->course;
+    }
+
+    public function getPeerReviews(): SimpleORMapCollection
+    {
+        $this->checkAutomaticPairing();
+
+        return SimpleORMapCollection::createFromArray(
+            PeerReview::findBySql('process_id = ? ORDER BY mkdate', [$this->getId()])
+        );
+    }
+
+    public function getDuration(): int
+    {
+        if (!isset($this->configuration['duration'])) {
+            return self::DEFAULT_DURATION;
+        }
+
+        return (int) $this->configuration['duration'];
+    }
+
+    public function isAnonymous(): bool
+    {
+        if (!isset($this->configuration['anonymous'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['anonymous'];
+    }
+
+    public function isAutomaticPairing(): bool
+    {
+        if (!isset($this->configuration['automaticPairing'])) {
+            return true;
+        }
+
+        return (bool) $this->configuration['automaticPairing'];
+    }
+
+    public function getCurrentState(int $date = null): string
+    {
+        if (is_null($date)) {
+            $date = time();
+        }
+
+        if ($this->review_end < $date) {
+            return self::STATE_AFTER;
+        }
+
+        if ($date < $this->review_start) {
+            return self::STATE_BEFORE;
+        }
+
+        return self::STATE_ACTIVE;
+    }
+
+    public function checkAutomaticPairing(): void
+    {
+        if ($this->isAutomaticPairing() && !$this->paired_at) {
+            $now = time();
+            if ($now > $this->review_start) {
+                $this->createAutomaticPairings();
+                $this->content['paired_at'] = $now;
+                $this->content_db['paired_at'] = $now;
+                $stmt = \DBManager::get()->prepare(
+                    'UPDATE `' . $this->db_table() . '` SET `paired_at` = ? WHERE id = ?'
+                );
+                $stmt->execute([$now, $this->getId()]);
+            }
+        }
+    }
+
+    public function createAutomaticPairings(): iterable
+    {
+        $taskGroup = $this->task_group;
+        $submitters = $taskGroup->getSubmitters();
+
+        if (count($submitters) < 2) {
+            return [];
+        }
+
+        shuffle($submitters);
+        $copy = $submitters;
+       $copy[] = array_shift($copy);
+        $pairings = array_map(null, $submitters, $copy);
+
+        return array_map(function ($pairing) use ($taskGroup) {
+            [$submitter, $reviewer] = $pairing;
+            $task = $taskGroup->findTaskBySolver($submitter);
+
+            return PeerReview::create([
+                'process_id' => $this->getId(),
+                'task_id' => $task->getId(),
+                'submitter_id' => $submitter->getId(),
+                'reviewer_id' => $reviewer->getId(),
+                'reviewer_type' => $reviewer instanceof User ? 'autor' : 'group',
+            ]);
+        }, $pairings);
+    }
+
+    public function rescheduleTo(int $newStartDate): void
+    {
+        $newEndDate = $newStartDate + $this->getDuration() * (24 * 60 * 60);
+        $this->setData([
+            'review_start' => $newStartDate,
+            'review_end' => $newEndDate,
+        ]);
+        $this->store();
+    }
+}
diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php
index bf3644cf4c4..3f7c56934e5 100644
--- a/lib/models/Courseware/StructuralElement.php
+++ b/lib/models/Courseware/StructuralElement.php
@@ -285,7 +285,7 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac
                 if ($this->range_id === $user->id) {
                     return true;
                 }
-                
+
                 return $this->hasWriteContentApproval($user);
 
             case 'course':
@@ -420,6 +420,8 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject, \Feedbac
                     }
 
                     return $task->userIsASolver($user);
+                    // TODO (mel): Das ist die ursprüngliche Variante, die aber jetzt kompliziert ist. Mit Nico sprechen!
+                    // return $task->userIsASolver($user) || $task->userIsAPeerReviewer($user);
                 }
 
                 if ($this->canEdit($user)) {
diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php
index 7842830050d..5f38ce9dfc1 100644
--- a/lib/models/Courseware/Task.php
+++ b/lib/models/Courseware/Task.php
@@ -2,6 +2,7 @@
 
 namespace Courseware;
 
+use Seminar_User;
 use User;
 
 /**
@@ -79,6 +80,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',
         ];
@@ -123,12 +132,11 @@ class Task extends \SimpleORMap
         return 1 === (int) $this->submitted;
     }
 
-    /**
-     * @param \User|\Seminar_User $user
-     */
-    public function canUpdate($user): bool
+    public function canUpdate(User|Seminar_User $user): bool
     {
-        $perm = false;
+        // TODO (mel): Das ist hier eine Code-Verdopplung gegenüber:
+        // $this->userIsASolver($user)
+        // Mit Nico besprechen
         switch ($this->solver_type) {
             case 'autor':
                 if ($this->solver_id === $user->id) {
@@ -157,10 +165,7 @@ class Task extends \SimpleORMap
         return $this->getStructuralElement()->hasEditingPermission($user);
     }
 
-    /**
-     * @param \User|\Seminar_User $user
-     */
-    public function userIsASolver($user): bool
+    public function userIsASolver(User|Seminar_User $user): bool
     {
         switch ($this->solver_type) {
             case 'autor':
@@ -175,6 +180,11 @@ class Task extends \SimpleORMap
         return false;
     }
 
+    public function userIsAPeerReviewer(User|Seminar_User $user): bool
+    {
+        return $this->isPeerReviewed() && $this->isPeerReviewedBy($user);
+    }
+
     /**
      * @return \User|\Statusgruppen|null the solver
      */
@@ -255,6 +265,53 @@ class Task extends \SimpleORMap
         $this->store();
     }
 
+    public function isPeerReviewed(): bool
+    {
+        return PeerReview::countBySql('task_id = ?', [$this->id]) !== 0;
+    }
+
+    public function isPeerReviewedBy(User|Seminar_User $user): bool
+    {
+        $sql = 'task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"';
+        if (PeerReview::countBySql($sql, [$this->id, $user->id]) !== 0) {
+            return true;
+        }
+
+        $sql = 'SELECT reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"';
+        foreach (\DBManager::get()->fetchFirst($sql, [$this->id]) as $reviewerId) {
+            if (\Statusgruppen::isMemberOf($reviewerId, $user->id)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function getPeerReviewProcessessWithReviewsBy(User|Seminar_User $user): array
+    {
+        return PeerReviewProcess::findBySql(
+            'id IN (?)',
+            array_unique(
+                array_merge(
+                    \DBManager::get()->fetchFirst(
+                        'SELECT DISTINCT process_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_id = ? AND reviewer_type = "autor"',
+                        [$this->id, $user->id]
+                    ),
+                    array_column(
+                        array_filter(
+                            \DBManager::get()->fetchAll(
+                                'SELECT process_id, reviewer_id FROM cw_peer_reviews WHERE task_id = ? AND reviewer_type = "group"',
+                                [$this->id]
+                            ),
+                            fn($row) => \Statusgruppen::isMemberOf($row['reviewer_id'], $user->id)
+                        ),
+                        'process_id'
+                    )
+                )
+            )
+        );
+    }
+
     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 6902cb36a67..626e7ccecb9 100644
--- a/lib/models/Courseware/TaskGroup.php
+++ b/lib/models/Courseware/TaskGroup.php
@@ -30,6 +30,7 @@ use User;
  * @property \Course $course belongs_to \Course
  * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement
  * @property \SimpleORMapCollection $tasks has_many Courseware\Task
+ * @property \SimpleORMapCollection $peer_review_processes has_many Courseware\PeerReviewProcess
  *
  * @SuppressWarnings(PHPMD.StaticAccess)
  */
@@ -62,6 +63,16 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             'order_by' => 'ORDER BY mkdate',
         ];
 
+        $config['has_many']['peer_review_processes'] = [
+            'class_name' => PeerReviewProcess::class,
+            'assoc_foreign_key' => 'task_group_id',
+            'on_delete' => 'delete',
+            'on_store' => 'store',
+            'order_by' => 'ORDER BY mkdate',
+        ];
+
+        $config['registered_callbacks']['after_store'][] = 'cbAfterStore';
+
         parent::configure($config);
     }
 
@@ -109,6 +120,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
         );
     }
 
+    public function hasPeerReviewProcesses(): bool
+    {
+        return PeerReviewProcess::countBySql('task_group_id = ?', [$this->getId()]) > 0;
+    }
+
     /**
      * Returns the task of this TaskGroup given to $solver.
      *
@@ -130,4 +146,19 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
         return empty($row) ? null : Task::find($row['id']);
     }
 
+    public function cbAfterStore(): void
+    {
+        if ($this->isFieldDirty('end_date')) {
+            $this->reschedulePeerReviewProcesses();
+        }
+    }
+
+    private function reschedulePeerReviewProcesses(): void
+    {
+        if ($this->hasPeerReviewProcesses()) {
+            foreach ($this->peer_review_processes as $process) {
+                $process->rescheduleTo($this->end_date);
+            }
+        }
+    }
 }
diff --git a/resources/assets/stylesheets/scss/wizard.scss b/resources/assets/stylesheets/scss/wizard.scss
index 4a9fd5d3ba7..9663f98e631 100644
--- a/resources/assets/stylesheets/scss/wizard.scss
+++ b/resources/assets/stylesheets/scss/wizard.scss
@@ -7,6 +7,7 @@
         width: 270px;
         min-height: 440px;
         margin-top: 38px;
+        flex-shrink: 0;
 
         img {
             margin: auto;
@@ -277,4 +278,3 @@ form.default fieldset.radiobutton-set {
         }
     }
 }
-
diff --git a/resources/vue/components/ConsultationCreator.vue b/resources/vue/components/ConsultationCreator.vue
index aa5621cf9af..03482c8354d 100644
--- a/resources/vue/components/ConsultationCreator.vue
+++ b/resources/vue/components/ConsultationCreator.vue
@@ -473,9 +473,7 @@ export default {
         combineDateAndTime(date, time) {
             const [hour, minute] = time.split(':').map(item => parseInt(item, 10));
             const result = new Date(date);
-            result.setHours(hour);
-            result.setMinutes(minute);
-            result.setSeconds(0);
+            result.setHours(hour, minute, 0, 0);
             return result;
         }
     },
diff --git a/resources/vue/components/StudipActionMenu.vue b/resources/vue/components/StudipActionMenu.vue
index a301ac3fa85..bb189f8892b 100644
--- a/resources/vue/components/StudipActionMenu.vue
+++ b/resources/vue/components/StudipActionMenu.vue
@@ -55,6 +55,7 @@
         <template v-for="item in navigationItems">
             <label v-if="item.disabled" :key="item.id" aria-disabled="true" v-bind="item.attributes">
                 <studip-icon :shape="item.icon"
+                             :alt="item.label"
                              :title="item.label"
                              role="inactive"
                              class="action-menu-item-icon"
@@ -63,6 +64,7 @@
             <span v-else-if="item.type === 'separator'" :key="item.id" class="quiet">|</span>
             <a v-else :key="item.id" v-bind="item.attributes" v-on="linkEvents(item)">
                 <studip-icon :shape="item.icon"
+                             :alt="item.label"
                              :title="item.label"
                              class="action-menu-item-icon"
                 ></studip-icon>
diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue
new file mode 100644
index 00000000000..68dffd0aad0
--- /dev/null
+++ b/resources/vue/components/StudipArticle.vue
@@ -0,0 +1,62 @@
+<template>
+    <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs">
+        <header>
+            <h1 @click="doToggle">
+                <template v-if="collapsable">
+                    <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" />
+                    <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else />
+                </template>
+                <slot name="title" v-bind="{ isOpen: collapsed }"></slot>
+            </h1>
+            <slot v-if="$slots.titleplus" name="titleplus"></slot>
+        </header>
+        <section v-if="!collapsed">
+            <slot name="body"></slot>
+        </section>
+        <footer v-if="$slots.footer">
+            <slot name="footer"></slot>
+        </footer>
+    </article>
+</template>
+
+<script>
+import StudipIcon from './StudipIcon.vue';
+
+export default {
+    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/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue
new file mode 100644
index 00000000000..1020839ab15
--- /dev/null
+++ b/resources/vue/components/StudipUserAvatar.vue
@@ -0,0 +1,38 @@
+<template>
+    <div class="studip-user-avatar" :class="{ 'studip-user-avatar-small': small }">
+        <span>
+            <img :src="avatarUrl" role="presentation" />
+        </span>
+        <span>{{ formattedName }}</span>
+    </div>
+</template>
+
+<script>
+export default {
+    props: {
+        avatarUrl: {
+            type: String,
+            required: true,
+        },
+        formattedName: {
+            type: String,
+            required: true,
+        },
+        small: {
+            type: Boolean,
+            default: false,
+        },
+    },
+};
+</script>
+
+<style scoped>
+.studip-user-avatar {
+    align-items: center;
+    display: flex;
+    gap: 0.25rem;
+}
+.studip-user-avatar-small img {
+    width: 1em;
+}
+</style>
diff --git a/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue b/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue
index 8b18192c5bf..eef8c736010 100644
--- a/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue
+++ b/resources/vue/components/courseware/blocks/CoursewareTableOfContentsBlock.vue
@@ -183,22 +183,20 @@ export default {
 
         getSolverName(taskId) {
             const task = this.taskById({ id: taskId });
-            if (task === undefined) {
-                return false;
-            }
-            const solver = task.relationships.solver.data;
-            if (solver.type === 'users') {
-                const user = this.userById({ id: solver.id });
+            if (task) {
+                const solver = task.relationships.solver.data;
+                if (solver?.type === 'users') {
+                    const user = this.userById({ id: solver.id });
 
-                return user.attributes['formatted-name'];
-            }
-            if (solver.type === 'status-groups') {
-                const group = this.groupById({ id: solver.id });
+                    return user.attributes['formatted-name'];
+                }
+                if (solver?.type === 'status-groups') {
+                    const group = this.groupById({ id: solver.id });
 
-                return group.attributes.name;
+                    return group.attributes.name;
+                }
             }
-
-            return false;
+            return null;
         },
     },
 };
diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
index 3e8ad878746..bf2af11afbe 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
@@ -44,8 +44,10 @@
                                     :title="structuralElement.attributes.title"
                                 >
                                     <span>{{ structuralElement.attributes.title || '–' }}</span>
-                                    <span v-if="isTask">[ {{ solverName }} ]</span>
-                                    <template v-if="!userIsTeacher && inCourse">
+                                    <span v-if="isTask">
+                                        [ {{ (!userIsSolver && userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ]
+                                    </span>
+                                    <template v-if="inCourse && !(userIsTeacher || userIsReviewer)">
                                         <studip-icon
                                             v-if="complete"
                                             shape="accept"
@@ -134,6 +136,9 @@
                                 </template>
                             </courseware-call-to-action-box>
                             <div v-if="structuralElementLoaded && !isLink" class="cw-companion-box-wrapper">
+                                <StudipMessageBox v-if="userIsReviewer">
+                                    {{ $gettext('Diese Seite gehört zu einer Aufgabe, die von einer anderen Person bearbeitet wird.') }}
+                                </StudipMessageBox>
                                 <courseware-companion-box
                                     v-if="!canVisit"
                                     mood="sad"
@@ -155,6 +160,22 @@
                                         </button>
                                     </template>
                                 </courseware-companion-box>
+                                <courseware-companion-box
+                                    v-for="peerReview in peerReviews"
+                                    :key="peerReview.id"
+                                    mood="pointing"
+                                    :msgCompanion="peerReviewCompanionMessage(peerReview)"
+                                    >
+                                    <template #companionActions>
+                                        <button
+                                            class="button"
+                                            @click="openPeerReview(peerReview)"
+                                            :disabled="!canReadPeerReviewAssessment(peerReview)"
+                                            >
+                                            {{ peerReviewCompanionAction(peerReview) }}
+                                        </button>
+                                    </template>
+                                </courseware-companion-box>
                                 <courseware-empty-element-box
                                     v-if="empty && !showRootLayout"
                                     :canEdit="canEdit"
@@ -370,6 +391,16 @@
                     v-if="showPublicLinkDialog && inContent"
                     :structuralElement="structuralElement"
                 />
+                <PeerReviewAssessmentDialog
+                    v-model:show="showPeerReviewAssessment"
+                    v-if="selectedPeerReview"
+                    :review="selectedPeerReview"
+                    />
+                <PeerReviewResultDialog
+                    v-model:show="showPeerReviewResult"
+                    v-if="selectedPeerReview"
+                    :review="selectedPeerReview"
+                    />
                 <feedback-dialog
                     v-if="showFeedbackDialog"
                     :feedbackElementId="parseInt(feedbackElementId)"
@@ -435,6 +466,9 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement
 
 import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue';
 import CoursewareRibbon from "./CoursewareRibbon.vue";
+import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue';
+import PeerReviewResultDialog from '../tasks/peer-review/ResultDialog.vue';
+import { getProcessStatus, ProcessStatus } from '../tasks/peer-review/definitions.ts';
 import CoursewareExport from '@/vue/mixins/courseware/export.js';
 
 import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -446,6 +480,7 @@ import { FocusTrap } from 'focus-trap-vue';
 import FeedbackDialog from '../../feedback/FeedbackDialog.vue';
 import FeedbackCreateDialog from '../../feedback/FeedbackCreateDialog.vue';
 import StudipFiveStars from '../../feedback/StudipFiveStars.vue';
+import StudipMessageBox from '../../StudipMessageBox.vue';
 import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
 import draggable from 'vuedraggable';
 import containerMixin from '@/vue/mixins/courseware/container.js';
@@ -481,7 +516,10 @@ export default {
         FeedbackCreateDialog,
         StudipFiveStars,
         FocusTrap,
+        PeerReviewAssessmentDialog,
+        PeerReviewResultDialog,
         StudipDialog,
+        StudipMessageBox,
         StudipProgressIndicator,
         draggable,
         CoursewareRibbon,
@@ -511,6 +549,9 @@ export default {
             consumModeTrap: false,
             keyboardSelected: null,
             assistiveLive: '',
+            showPeerReviewAssessment: false,
+            showPeerReviewResult: false,
+            selectedPeerReview: null,
             displayFeedback: false,
             showRatingPopup: false,
             ratingPopupFeedbackElement: null,
@@ -535,6 +576,8 @@ export default {
             context: 'context',
             containerById: 'courseware-containers/byId',
             relatedContainers: 'courseware-containers/related',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
             relatedStructuralElements: 'courseware-structural-elements/related',
             getRelatedFeedback: 'courseware-structural-element-feedback/related',
             getRelatedComments: 'courseware-structural-element-comments/related',
@@ -645,7 +688,7 @@ export default {
             if (this.context.type === 'courses' && this.currentElement.relationships) {
                 if (
                     this.currentElement.relationships.course &&
-                    this.context.id === this.currentElement.relationships.course.data.id
+                        this.context.id === this.currentElement.relationships.course.data.id
                 ) {
                     return true;
                 }
@@ -654,7 +697,7 @@ export default {
             if (this.context.type === 'users' && this.currentElement.relationships) {
                 if (
                     this.currentElement.relationships.user &&
-                    this.context.id === this.currentElement.relationships.user.data.id
+                        this.context.id === this.currentElement.relationships.user.data.id
                 ) {
                     return true;
                 }
@@ -969,10 +1012,10 @@ export default {
         solver() {
             if (this.task) {
                 const solver = this.task.relationships.solver.data;
-                if (solver.type === 'users') {
+                if (solver?.type === 'users') {
                     return this.userById({ id: solver.id });
                 }
-                if (solver.type === 'status-groups') {
+                if (solver?.type === 'status-groups') {
                     return this.groupById({ id: solver.id });
                 }
             }
@@ -988,8 +1031,7 @@ export default {
                     return this.solver.attributes.name;
                 }
             }
-
-            return '';
+            return null;
         },
         canAddElements() {
             if (!this.isTask) {
@@ -1042,7 +1084,7 @@ export default {
         },
         elementProgress() {
             if (this.structuralElementLoaded) {
-                return this.progressData?.[this.structuralElement.id].progress.self;
+                return this.progressData?.[this.structuralElement.id].progress?.self ?? 0;
             }
 
             return 0;
@@ -1106,6 +1148,30 @@ export default {
                 { length: this.commentsCounter }
             );
         },
+        userIsReviewer() {
+            return this.peerReviews.some((peerReview) => peerReview.attributes['is-reviewer']);
+        },
+        userIsSolver() {
+            return this.peerReviews.some((peerReview) => peerReview.attributes['is-submitter']);
+        },
+        peerReviews() {
+            if (this.task) {
+                return this.relatedPeerReviews({
+                    parent: { id: this.task.id, type: this.task.type },
+                    relationship: 'peer-reviews',
+                }) ?? [];
+            }
+            return [];
+        },
+        isPeerReviewAnonymous() {
+            return this.peerReviews.every(({ id, type }) => {
+                const process = this.relatedPeerReviewProcesses({
+                    parent: { id, type },
+                    relationship: 'process',
+                });
+                return process.attributes.configuration.anonymous;
+            });
+        },
     },
 
     methods: {
@@ -1366,17 +1432,19 @@ export default {
                 ref.initCurrentData();
             }
         },
-        async loadFeedback() {
+        loadFeedback() {
             const parent = {
                 type: this.currentElement.type,
                 id: this.currentElement.id,
             };
-            await this.loadRelatedFeedback({
+            return this.loadRelatedFeedback({
                 parent,
                 relationship: 'feedback',
                 options: {
                     include: 'user',
                 },
+            }).catch((error) => {
+                console.error("Could not load feedback");
             });
         },
         keyHandler(e, containerId) {
@@ -1605,6 +1673,52 @@ export default {
                 }
             });
         },
+
+        getPeerReviewProcess(review) {
+            return this.relatedPeerReviewProcesses({
+                parent: { id: review.id, type: review.type },
+                relationship: 'process',
+            });
+        },
+        canReadPeerReviewAssessment(peerReview) {
+            if (peerReview.attributes['is-reviewer']) {
+                return true;
+            }
+            const process = this.getPeerReviewProcess(peerReview);
+            const isAfter = getProcessStatus(process)?.status === ProcessStatus.After;
+            return (this.userIsTeacher || peerReview.attributes['is-submitter']) && isAfter;
+        },
+        openPeerReview(peerReview) {
+            this.selectedPeerReview = peerReview;
+            if (peerReview.attributes['is-reviewer']) {
+                this.showPeerReviewAssessment = true;
+            } else {
+                this.showPeerReviewResult = true;
+            }
+        },
+        peerReviewCompanionAction(peerReview) {
+            const process = this.getPeerReviewProcess(peerReview);
+            if (peerReview.attributes['is-reviewer'] && getProcessStatus(process)?.status === ProcessStatus.Active) {
+                return this.$gettext('Peer-Review geben');
+            }
+            return this.$gettext('Peer-Review einsehen');
+        },
+        peerReviewCompanionMessage(peerReview) {
+            let message;
+            if (peerReview.attributes['is-reviewer']) {
+                message = this.$gettext('Sie beurteilen diese Aufgabe im Rahmen eines Peer-Reviews.');
+            } else if (peerReview.attributes['is-submitter']) {
+                message = this.$gettext('Sie haben zu Ihrer Aufgabe ein Peer-Review erhalten.');
+            } else {
+                message = this.$gettext('Diese Aufgabe hat ein Peer-Review erhalten.');
+            }
+
+            if (this.canReadPeerReviewAssessment(peerReview)) {
+                return message;
+            }
+
+            return `${message} ${this.$gettext('Sie können es jedoch nicht öffnen, da der Bearbeitungszeitraum noch nicht abgelaufen ist.')}`;
+        },
     },
     created() {
         this.pluginManager.registerComponentsLocally(this);
diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
index 01e894cf028..9ad0eb1d0bb 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue
@@ -32,7 +32,7 @@
                     <studip-icon shape="edit" />
                 </button>
 
-                <span v-if="task">| {{ solverName }}</span>
+                <span v-if="task">| {{ solverName ?? $gettext("anonym") }}</span>
                 <span
                     v-if="hasReleaseOrWithdrawDate"
                     class="cw-tree-item-flag-date"
@@ -44,7 +44,7 @@
                     :title="canWriteFlagTitle"
                 ></span>
                 <span v-if="hasNoReadApproval" class="cw-tree-item-flag-cant-read" :title="cantReadFlagTitle"></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"
@@ -408,10 +408,10 @@ export default {
         solver() {
             if (this.task) {
                 const solver = this.task.relationships.solver.data;
-                if (solver.type === 'users') {
+                if (solver?.type === 'users') {
                     return this.userById({ id: solver.id });
                 }
-                if (solver.type === 'status-groups') {
+                if (solver?.type === 'status-groups') {
                     return this.groupById({ id: solver.id });
                 }
             }
@@ -428,7 +428,7 @@ export default {
                 }
             }
 
-            return '';
+            return null;
         },
         isTask() {
             return this.element.attributes?.purpose === 'task';
@@ -462,6 +462,9 @@ export default {
         complete() {
             return this.itemProgress === 100;
         },
+        userIsReviewer() {
+            return this.task ? this.task.attributes['can-peer-review'] : false;
+        },
     },
     methods: {
         ...mapActions({
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
index 889b64d3f4f..f38d908ee49 100644
--- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -3,7 +3,8 @@
         <ContentBar isContentBar>
             <template #buttons-left>
                 <router-link :to="{ name: 'task-groups-index' }">
-                    <StudipIcon shape="category-task" :size="24" />
+                    <StudipIcon shape="category-task" :size="24" aria-role="presentation" />
+                    <span class="sr-only">{{ $gettext('Aufgaben') }}</span>
                 </router-link>
             </template>
             <template #breadcrumb-list>
@@ -26,6 +27,7 @@
                     <th :class="getSortClass('end-date')" @click="sort('end-date')">
                         {{ $gettext('Bearbeitungszeit') }}
                     </th>
+                    <th></th>
                     <th class="actions">{{ $gettext('Aktionen') }}</th>
                 </tr>
             </thead>
@@ -46,9 +48,13 @@
                         }}</router-link>
                     </td>
                     <td>
-                        <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - <StudipDate
-                            :date="new Date(taskGroup.attributes['end-date'])"
-                        />
+                        <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> -
+                        <StudipDate :date="new Date(taskGroup.attributes['end-date'])" />
+                    </td>
+                    <td>
+                        <div v-for="process in peerReviewProcesses(taskGroup)" :key="process.id">
+                            <PeerReviewProcessStatus :process="process" description :filter="processActive" />
+                        </div>
                     </td>
                     <td class="actions">
                         <StudipActionMenu
@@ -70,7 +76,11 @@
             </template>
         </CompanionBox>
 
-        <TaskGroupsAddSolversDialog v-if="showTaskGroupsAddSolversDialog" :taskGroup="selectedTaskGroup" @newtask="reloadTasks" />
+        <TaskGroupsAddSolversDialog
+            v-if="showTaskGroupsAddSolversDialog"
+            :taskGroup="selectedTaskGroup"
+            @newtask="reloadTasks"
+        />
         <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="selectedTaskGroup" />
         <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="selectedTaskGroup" />
         <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" />
@@ -82,6 +92,7 @@ import _ from 'lodash';
 import { mapActions, mapGetters } from 'vuex';
 import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
 import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import PeerReviewProcessStatus from './peer-review/ProcessStatus.vue';
 import StudipActionMenu from '../../StudipActionMenu.vue';
 import StudipDate from '../../StudipDate.vue';
 import StudipIcon from '../../StudipIcon.vue';
@@ -90,6 +101,7 @@ import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
 import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
 import { getStatus } from './task-groups-helper.js';
 import ContentBar from "../../ContentBar.vue";
+import { ProcessStatus } from './peer-review/definitions.ts';
 
 export default {
     name: 'courseware-dashboard-students',
@@ -97,6 +109,7 @@ export default {
         ContentBar,
         CompanionBox,
         CoursewareTasksDialogDistribute,
+        PeerReviewProcessStatus,
         StudipActionMenu,
         StudipDate,
         StudipIcon,
@@ -105,6 +118,7 @@ export default {
         TaskGroupsModifyDeadlineDialog,
     },
     data: () => ({
+        processActive: ProcessStatus.Active,
         selectedTaskGroup: null,
         sortBy: 'end-date',
         sortAsc: false,
@@ -112,6 +126,7 @@ export default {
     computed: {
         ...mapGetters({
             context: 'context',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
             showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog',
             showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog',
             showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog',
@@ -157,13 +172,13 @@ export default {
                     id: 'add-solvers',
                     label: this.$gettext('Teilnehmende hinzufügen'),
                     icon: 'add',
-                    emit: 'addsolvers'
+                    emit: 'addsolvers',
                 });
                 menuItems.push({
                     id: 'modify-deadline',
                     label: this.$gettext('Bearbeitungszeit verlängern'),
                     icon: 'date',
-                    emit: 'deadline'
+                    emit: 'deadline',
                 });
             }
 
@@ -188,11 +203,15 @@ export default {
             this.selectedTaskGroup = taskGroup;
             this.setShowTaskGroupsModifyDeadlineDialog(true);
         },
+        peerReviewProcesses(parent) {
+            return this.relatedPeerReviewProcesses({ parent, relationship: 'peer-review-processes' });
+        },
         reloadTasks() {
             this.loadAllTasks({
                 options: {
                     'filter[cid]': this.context.id,
-                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                    include:
+                        'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes',
                 },
             });
         },
@@ -212,7 +231,7 @@ export default {
 th {
     cursor: pointer;
 }
-th:is(:first-child,:last-child) {
+th:is(:first-child, :last-child) {
     cursor: not-allowed;
 }
 </style>
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
index 9bf7b635657..09227a957ac 100644
--- a/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
@@ -1,351 +1,20 @@
 <template>
-    <div class="cw-dashboard-tasks-wrapper">
-        <table v-if="tasks.length > 0" class="default">
-            <colgroup>
-                <col style="width: 5%" />
-                <col style="width: 20%" />
-                <col style="width: 10%" />
-                <col style="width: 10%" />
-                <col style="width: 5%" />
-                <col style="width: 15%" />
-                <col style="width: 15%" />
-                <col style="width: 15%" />
-                <col style="width: 5%" />
-            </colgroup>
-            <thead>
-                <tr>
-                    <th>{{ $gettext('Status') }}</th>
-                    <th>{{ $gettext('Aufgabe') }}</th>
-                    <th>{{ $gettext('bearbeitet') }}</th>
-                    <th>{{ $gettext('Abgabefrist') }}</th>
-                    <th>{{ $gettext('Abgabe') }}</th>
-                    <th class="responsive-hidden">{{ $gettext('Verlängerungsanfrage') }}</th>
-                    <th class="responsive-hidden">{{ $gettext('Für Teilnehmende freigeben') }}</th>
-                    <th class="responsive-hidden">{{ $gettext('Anmerkung') }}</th>
-                    <th class="actions">{{ $gettext('Aktionen') }}</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr v-for="{ task, taskGroup, status, element, feedback } in tasks" :key="task.id">
-                    <td>
-                        <studip-icon
-                            v-if="status.shape !== undefined"
-                            :shape="status.shape"
-                            :role="status.role"
-                            :title="status.description"
-                        />
-                    </td>
-                    <td>
-                        <a :href="getLinkToElement(element)">
-                            <studip-icon
-                                v-if="task.attributes['solver-type'] === 'group'"
-                                shape="group2"
-                                :title="$gettext('Gruppenaufgabe')"
-                            />
-                            {{ taskGroup.attributes.title }}
-                        </a>
-                    </td>
-                    <td>{{ task.attributes?.progress?.toFixed(0) || '-' }}%</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">
-                        <span v-show="task.attributes.renewal === 'declined'">
-                            <studip-icon shape="decline" role="status-red" />
-                            {{ $gettext('Anfrage abgelehnt') }}
-                        </span>
-                        <span v-show="task.attributes.renewal === 'pending'">
-                            <studip-icon shape="date" role="status-yellow" />
-                            {{ $gettext('Anfrage wird bearbeitet') }}
-                        </span>
-                        <span v-show="task.attributes.renewal === 'granted'">
-                            {{ $gettext('verlängert bis') }}: {{ getReadableDate(task.attributes['renewal-date']) }}
-                        </span>
-                    </td>
-                    <td class="responsive-hidden">
-                        <span v-if="task.attributes.submitted">
-                            <button
-                                class="button"
-                                v-if="!task.attributes.visible"
-                                @click="toggleVisibilityOn(task)"
-                            >
-                                {{ $gettext('Freigeben') }}
-                            </button>
-                            <button
-                                class="button"
-                                v-if="task.attributes.visible"
-                                @click="toggleVisibilityOff(task)">
-                                {{ $gettext('Freigabe widerrufen') }}
-                            </button>
-                        </span>
-                    </td>
-                    <td class="responsive-hidden">
-                        <studip-icon
-                            v-if="feedback"
-                            :title="$gettext('Anmerkung anzeigen')"
-                            class="display-feedback"
-                            shape="consultation"
-                            role="clickable"
-                            @click="displayFeedback(feedback)"
-                        />
-                    </td>
-                    <td class="actions">
-                        <studip-action-menu
-                            :items="getTaskMenuItems(task, status, element)"
-                            @submitTask="displaySubmitDialog(task)"
-                            @renewalRequest="renewalRequest(task)"
-                            @copyContent="copyContent(taskGroup, element)"
-                        />
-                    </td>
-                </tr>
-            </tbody>
-        </table>
-        <div v-else>
-            <courseware-companion-box
-                mood="sad"
-                :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')"
-            />
-        </div>
-        <studip-dialog
-            v-if="showFeedbackDialog"
-            :message="currentTaskFeedback"
-            :title="text.feedbackDialog.title"
-            @close="
-                showFeedbackDialog = false;
-                currentTaskFeedback = '';
-            "
-        />
-        <studip-dialog
-            v-if="showSubmitDialog"
-            :title="text.submitDialog.title"
-            :question="text.submitDialog.question"
-            height="200"
-            width="420"
-            @confirm="submitTask"
-            @close="closeSubmitDialog"
-        />
+    <div class="courseware-dashboard-tasks">
+        <TasksList />
+        <ProcessesList />
     </div>
 </template>
 <script>
-import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
-import StudipIcon from '../../StudipIcon.vue';
-import StudipActionMenu from '../../StudipActionMenu.vue';
-import StudipDialog from '../../StudipDialog.vue';
-import taskHelperMixin from '../../../mixins/courseware/task-helper.js';
-import { mapActions, mapGetters } from 'vuex';
+import TasksList from './CoursewareDashboardTasksList.vue';
+import ProcessesList from './peer-review/ProcessesList.vue';
 
 export default {
-    name: 'courseware-dashboard-tasks',
-    mixins: [taskHelperMixin],
-    components: {
-        CoursewareCompanionBox,
-        StudipIcon,
-        StudipActionMenu,
-        StudipDialog,
-    },
-    data() {
-        return {
-            showFeedbackDialog: false,
-            showSubmitDialog: false,
-            currentTask: null,
-            currentTaskFeedback: '',
-            text: {
-                feedbackDialog: {
-                    title: this.$gettext('Anmerkung'),
-                },
-                submitDialog: {
-                    title: this.$gettext('Aufgabe abgeben'),
-                    question: this.$gettext(
-                        'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?'
-                    ),
-                },
-            },
-        };
-    },
-    computed: {
-        ...mapGetters({
-            context: 'context',
-            allTasks: 'courseware-tasks/all',
-            userId: 'userId',
-            userById: 'users/byId',
-            statusGroupById: 'status-groups/byId',
-            getElementById: 'courseware-structural-elements/byId',
-            getFeedbackById: 'courseware-task-feedback/byId',
-            getTaskGroupById: 'courseware-task-groups/byId',
-            lastCreateCoursewareUnit: 'courseware-units/lastCreated',
-        }),
-        tasks() {
-            return this.allTasks.map((task) => {
-                const result = {
-                    task,
-                    taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }),
-                    status: this.getStatus(task),
-                    element: this.getElementById({ id: task.relationships['structural-element'].data.id }),
-                    feedback: null,
-                };
-                const feedbackId = task.relationships['task-feedback'].data?.id;
-                if (feedbackId) {
-                    result.feedback = this.getFeedbackById({ id: feedbackId });
-                }
-
-                return result;
-            });
-        },
-        taskVisibilities() {
-            let visibilities = [];
-            for (const task of this.tasks) {
-                visibilities[`${task.task.id}`] = task.element.attributes.payload['task-visibility'];
-            }
-            return visibilities;
-        }
-    },
-    methods: {
-        ...mapActions({
-            updateTask: 'updateTask',
-            loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure',
-            copyStructuralElement: 'copyStructuralElement',
-            companionSuccess: 'companionSuccess',
-            companionError: 'companionError',
-            createCoursewareUnit: 'courseware-units/create',
-            loadStructuralElement: 'courseware-structural-elements/loadById'
-        }),
-        getTaskMenuItems(task, status, element) {
-            let menuItems = [];
-            if (!task.attributes.submitted && status.canSubmit) {
-                menuItems.push({
-                    id: 1,
-                    label: this.$gettext('Aufgabe bearbeiten'),
-                    icon: 'edit',
-                    url: this.getLinkToElement(element),
-                });
-                menuItems.push({ id: 2, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' });
-            }
-
-            if (!task.attributes.submitted && !task.attributes.renewal) {
-                menuItems.push({
-                    id: 3,
-                    label: this.$gettext('Verlängerung beantragen'),
-                    icon: 'date',
-                    emit: 'renewalRequest',
-                });
-            }
-            if (task.attributes.submitted) {
-                menuItems.push({ id: 4, label: this.$gettext('Inhalt auf Arbeitsplatz kopieren'), icon: 'export', emit: 'copyContent' });
-            }
-
-            return menuItems;
-        },
-        async renewalRequest(task) {
-            let attributes = task.attributes;
-            attributes.renewal = 'pending';
-            await this.updateTask({
-                attributes: attributes,
-                taskId: task.id,
-            });
-            this.companionSuccess({
-                info: this.$gettext('Ihre Anfrage wurde eingereicht.'),
-            });
-        },
-        displaySubmitDialog(task) {
-            this.showSubmitDialog = true;
-            this.currentTask = task;
-        },
-        closeSubmitDialog() {
-            this.showSubmitDialog = false;
-            this.currentTask = null;
-        },
-        async submitTask() {
-            const currentTaskGroup = this.getTaskGroupById({ id: this.currentTask.relationships['task-group'].data.id });
-            this.showSubmitDialog = false;
-            let attributes = {};
-            attributes.submitted = true;
-            await this.updateTask({
-                attributes: attributes,
-                taskId: this.currentTask.id,
-            });
-            this.companionSuccess({
-                info: '"' + currentTaskGroup.attributes.title + '" ' + this.$gettext('wurde erfolgreich abgegeben.'),
-            });
-            this.currentTask = null;
-        },
-        async copyContent(taskGroup, element) {
-            const unit = {
-                attributes: {
-                    title: taskGroup.attributes.title,
-                    purpose: 'content',
-                    payload: {
-                        description: '',
-                        color: 'studip-blue',
-                        license_type: '',
-                        required_time: '',
-                        difficulty_start: '',
-                        difficulty_end: ''
-                    },
-                    settings: {
-                        'root-layout': 'classic'
-                    }
-                },
-                relationships: {
-                    range: {
-                        data: {
-                            type: 'users',
-                            id: this.userId
-                        }
-                    }
-                }
-            };
-            await this.createCoursewareUnit(unit, { root: true });
-            const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id
-            await this.copyStructuralElement({
-                parentId: newElementId,
-                elementId: element.id,
-                removeType: false,
-                migrate: true,
-            });
-            this.companionSuccess({
-                info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'),
-            });
-        },
-        displayFeedback(feedback) {
-            this.showFeedbackDialog = true;
-            this.currentTaskFeedback = feedback.attributes.content;
-        },
-        toggleVisibilityOn(task) {
-            let attributes = task.attributes;
-            attributes['visible'] = true;
-            this.toggleVisibility(task, attributes);
-        },
-        toggleVisibilityOff(task) {
-            let attributes = task.attributes;
-            attributes['visible'] = false;
-            this.toggleVisibility(task, attributes);
-        },
-        async toggleVisibility(task, attributes) {
-            await this.updateTask({
-                attributes: attributes,
-                taskId: task.id,
-            });
-
-            const taskGroup = this.getTaskGroupById({ id: task.relationships['task-group'].data.id });
-            const taskTitle = taskGroup.attributes.title;
-
-            if (attributes.visible) {
-                this.companionSuccess({
-                    info: this.$gettext(
-                        '"%{ title }" wurde freigegeben.',
-                        { title: taskTitle }
-                    ),
-                });
-            } else {
-                this.companionSuccess({
-                    info: this.$gettext(
-                        'Die Freigabe für %{ "title }" wurde zurückgenommen.',
-                        { title: taskTitle }
-                    ),
-                });
-            }
-        }
-    },
+    components: { ProcessesList, TasksList },
 };
 </script>
+
+<style scoped>
+.courseware-dashboard-tasks > * + * {
+    margin-block-start: 2rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue
new file mode 100644
index 00000000000..41ac8736bed
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasksList.vue
@@ -0,0 +1,351 @@
+<template>
+    <div class="cw-dashboard-tasks-wrapper">
+        <table v-if="solvableTasks.length > 0" class="default">
+            <caption>
+                {{ $gettext('Aufgaben') }}
+            </caption>
+            <colgroup>
+                <col style="width: 5%" />
+                <col style="width: 20%" />
+                <col style="width: 10%" />
+                <col style="width: 10%" />
+                <col style="width: 5%" />
+                <col style="width: 15%" />
+                <col style="width: 15%" />
+                <col style="width: 15%" />
+                <col style="width: 5%" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Status') }}</th>
+                    <th>{{ $gettext('Aufgabe') }}</th>
+                    <th>{{ $gettext('bearbeitet') }}</th>
+                    <th>{{ $gettext('Abgabefrist') }}</th>
+                    <th>{{ $gettext('Abgabe') }}</th>
+                    <th class="responsive-hidden">{{ $gettext('Verlängerungsanfrage') }}</th>
+                    <th class="responsive-hidden">{{ $gettext('Für Teilnehmende freigeben') }}</th>
+                    <th class="responsive-hidden">{{ $gettext('Anmerkung') }}</th>
+                    <th class="actions">{{ $gettext('Aktionen') }}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="{ task, taskGroup, status, element, feedback } in solvableTasks" :key="task.id">
+                    <td>
+                        <studip-icon
+                            v-if="status.shape !== undefined"
+                            :shape="status.shape"
+                            :role="status.role"
+                            :title="status.description"
+                        />
+                    </td>
+                    <td>
+                        <a :href="getLinkToElement(element)">
+                            <studip-icon
+                                v-if="task.attributes['solver-type'] === 'group'"
+                                shape="group2"
+                                :title="$gettext('Gruppenaufgabe')"
+                            />
+                            {{ taskGroup.attributes.title }}
+                        </a>
+                    </td>
+                    <td>{{ task.attributes?.progress?.toFixed(0) || '-' }}%</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">
+                        <span v-show="task.attributes.renewal === 'declined'">
+                            <studip-icon shape="decline" role="status-red" />
+                            {{ $gettext('Anfrage abgelehnt') }}
+                        </span>
+                        <span v-show="task.attributes.renewal === 'pending'">
+                            <studip-icon shape="date" role="status-yellow" />
+                            {{ $gettext('Anfrage wird bearbeitet') }}
+                        </span>
+                        <span v-show="task.attributes.renewal === 'granted'">
+                            {{ $gettext('verlängert bis') }}: {{ getReadableDate(task.attributes['renewal-date']) }}
+                        </span>
+                    </td>
+                    <td class="responsive-hidden">
+                        <span v-if="task.attributes.submitted">
+                            <button
+                                class="button"
+                                v-if="!task.attributes.visible"
+                                @click="toggleVisibilityOn(task)"
+                            >
+                                {{ $gettext('Freigeben') }}
+                            </button>
+                            <button
+                                class="button"
+                                v-if="task.attributes.visible"
+                                @click="toggleVisibilityOff(task)">
+                                {{ $gettext('Freigabe widerrufen') }}
+                            </button>
+                        </span>
+                    </td>
+                    <td class="responsive-hidden">
+                        <studip-icon
+                            v-if="feedback"
+                            :title="$gettext('Anmerkung anzeigen')"
+                            class="display-feedback"
+                            shape="consultation"
+                            role="clickable"
+                            @click="displayFeedback(feedback)"
+                        />
+                    </td>
+                    <td class="actions">
+                        <studip-action-menu
+                            :items="getTaskMenuItems(task, status, element)"
+                            @submitTask="displaySubmitDialog(task)"
+                            @renewalRequest="renewalRequest(task)"
+                            @copyContent="copyContent(taskGroup, element)"
+                        />
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <div v-else>
+            <courseware-companion-box
+                mood="sad"
+                :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')"
+            />
+        </div>
+        <studip-dialog
+            v-if="showFeedbackDialog"
+            :message="currentTaskFeedback"
+            :title="text.feedbackDialog.title"
+            @close="
+                showFeedbackDialog = false;
+                currentTaskFeedback = '';
+            "
+        />
+        <studip-dialog
+            v-if="showSubmitDialog"
+            :title="text.submitDialog.title"
+            :question="text.submitDialog.question"
+            height="200"
+            width="420"
+            @confirm="submitTask"
+            @close="closeSubmitDialog"
+        />
+    </div>
+</template>
+<script>
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDialog from '../../StudipDialog.vue';
+import taskHelperMixin from '../../../mixins/courseware/task-helper.js';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    name: 'courseware-dashboard-tasks',
+    mixins: [taskHelperMixin],
+    components: {
+        CoursewareCompanionBox,
+        StudipIcon,
+        StudipActionMenu,
+        StudipDialog,
+    },
+    data() {
+        return {
+            showFeedbackDialog: false,
+            showSubmitDialog: false,
+            currentTask: null,
+            currentTaskFeedback: '',
+            text: {
+                feedbackDialog: {
+                    title: this.$gettext('Anmerkung'),
+                },
+                submitDialog: {
+                    title: this.$gettext('Aufgabe abgeben'),
+                    question: this.$gettext(
+                        'Änderungen sind nach Abgabe nicht mehr möglich. Möchten Sie diese Aufgabe jetzt wirklich abgeben?'
+                    ),
+                },
+            },
+        };
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            allTasks: 'courseware-tasks/all',
+            userId: 'userId',
+            userById: 'users/byId',
+            statusGroupById: 'status-groups/byId',
+            getElementById: 'courseware-structural-elements/byId',
+            getFeedbackById: 'courseware-task-feedback/byId',
+            getTaskGroupById: 'courseware-task-groups/byId',
+            lastCreateCoursewareUnit: 'courseware-units/lastCreated',
+        }),
+        solvableTasks() {
+            return this.tasks.filter(({ task }) => task.attributes['can-solve']);
+        },
+        tasks() {
+            return this.allTasks.map((task) => {
+                const result = {
+                    task,
+                    taskGroup: this.getTaskGroupById({ id: task.relationships['task-group'].data.id }),
+                    status: this.getStatus(task),
+                    element: this.getElementById({ id: task.relationships['structural-element'].data.id }),
+                    feedback: null,
+                };
+                const feedbackId = task.relationships['task-feedback'].data?.id;
+                if (feedbackId) {
+                    result.feedback = this.getFeedbackById({ id: feedbackId });
+                }
+
+                return result;
+            });
+        },
+        taskVisibilities() {
+            let visibilities = [];
+            for (const task of this.tasks) {
+                visibilities[`${task.task.id}`] = task.element.attributes.payload['task-visibility'];
+            }
+            return visibilities;
+        }
+    },
+    methods: {
+        ...mapActions({
+            updateTask: 'updateTask',
+            loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure',
+            copyStructuralElement: 'copyStructuralElement',
+            companionSuccess: 'companionSuccess',
+            companionError: 'companionError',
+            createCoursewareUnit: 'courseware-units/create',
+            loadStructuralElement: 'courseware-structural-elements/loadById'
+        }),
+        getTaskMenuItems(task, status, element) {
+            let menuItems = [];
+            if (!task.attributes.submitted && status.canSubmit) {
+                menuItems.push({
+                    id: 1,
+                    label: this.$gettext('Aufgabe bearbeiten'),
+                    icon: 'edit',
+                    url: this.getLinkToElement(element),
+                });
+                menuItems.push({ id: 2, label: this.$gettext('Aufgabe abgeben'), icon: 'service', emit: 'submitTask' });
+            }
+
+            if (!task.attributes.submitted && !task.attributes.renewal) {
+                menuItems.push({
+                    id: 3,
+                    label: this.$gettext('Verlängerung beantragen'),
+                    icon: 'date',
+                    emit: 'renewalRequest',
+                });
+            }
+            if (task.attributes.submitted) {
+                menuItems.push({ id: 4, label: this.$gettext('Inhalt auf Arbeitsplatz kopieren'), icon: 'export', emit: 'copyContent' });
+            }
+
+            return menuItems;
+        },
+        async renewalRequest(task) {
+            let attributes = task.attributes;
+            attributes.renewal = 'pending';
+            await this.updateTask({
+                attributes: attributes,
+                taskId: task.id,
+            });
+            this.companionSuccess({
+                info: this.$gettext('Ihre Anfrage wurde eingereicht.'),
+            });
+        },
+        displaySubmitDialog(task) {
+            this.showSubmitDialog = true;
+            this.currentTask = task;
+        },
+        closeSubmitDialog() {
+            this.showSubmitDialog = false;
+            this.currentTask = null;
+        },
+        async submitTask() {
+            const currentTaskGroup = this.getTaskGroupById({ id: this.currentTask.relationships['task-group'].data.id });
+            this.showSubmitDialog = false;
+            let attributes = {};
+            attributes.submitted = true;
+            await this.updateTask({
+                attributes: attributes,
+                taskId: this.currentTask.id,
+            });
+            this.companionSuccess({
+                info: '"' + currentTaskGroup.attributes.title + '" ' + this.$gettext('wurde erfolgreich abgegeben.'),
+            });
+            this.currentTask = null;
+        },
+        async copyContent(taskGroup, element) {
+            const unit = {
+                attributes: {
+                    title: taskGroup.attributes.title,
+                    purpose: 'content',
+                    payload: {
+                        description: '',
+                        color: 'studip-blue',
+                        license_type: '',
+                        required_time: '',
+                        difficulty_start: '',
+                        difficulty_end: ''
+                    },
+                    settings: {
+                        'root-layout': 'classic'
+                    }
+                },
+                relationships: {
+                    range: {
+                        data: {
+                            type: 'users',
+                            id: this.userId
+                        }
+                    }
+                }
+            };
+            await this.createCoursewareUnit(unit, { root: true });
+            const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id
+            await this.copyStructuralElement({
+                parentId: newElementId,
+                elementId: element.id,
+                removeType: false,
+                migrate: true,
+            });
+            this.companionSuccess({
+                info: this.$gettext('Die Inhalte wurden zu Ihren persönlichen Lernmaterialien hinzugefügt.'),
+            });
+        },
+        displayFeedback(feedback) {
+            this.showFeedbackDialog = true;
+            this.currentTaskFeedback = feedback.attributes.content;
+        },
+        toggleVisibilityOn(task) {
+            let attributes = task.attributes;
+            attributes['visible'] = true;
+            this.toggleVisibility(task, attributes);
+        },
+        toggleVisibilityOff(task) {
+            let attributes = task.attributes;
+            attributes['visible'] = false;
+            this.toggleVisibility(task, attributes);
+        },
+        async toggleVisibility(task, attributes) {
+            await this.updateTask({
+                attributes: attributes,
+                taskId: task.id,
+            });
+
+            const taskGroup = this.getTaskGroupById({ id: task.relationships['task-group'].data.id });
+            const taskTitle = taskGroup.attributes.title;
+
+            if (attributes.visible) {
+                this.companionSuccess({
+                    info: this.$gettext('"%{ title }" wurde freigegeben.', { title: taskTitle }),
+                });
+            } else {
+                this.companionSuccess({
+                    info: this.$gettext('Die Freigabe für %{ "title }" wurde zurückgenommen.', { title: taskTitle }),
+                });
+            }
+        }
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
index 525a9c12354..6e7f0343af3 100644
--- a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
@@ -530,15 +530,9 @@ export default {
             }
             this.distributing = true;
             const startDate = new Date(this.startDate);
-            startDate.setHours(0);
-            startDate.setMinutes(0);
-            startDate.setSeconds(0);
-            startDate.setMilliseconds(0);
+            startDate.setHours(0, 0, 0, 0);
             const endDate = new Date(this.endDate);
-            endDate.setHours(23);
-            endDate.setMinutes(59);
-            endDate.setSeconds(59);
-            endDate.setMilliseconds(999);
+            endDate.setHours(23, 59, 59, 999);
             const taskGroup = {
                 attributes: {
                     title: this.taskTitle,
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
index a0ec342e1ca..f72f8f059df 100644
--- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -1,14 +1,18 @@
 <template>
     <div class="cw-tasks-wrapper">
         <Teleport to="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
-            <CoursewareTasksActionWidget :taskGroup="taskGroup" />
+            <CoursewareTasksActionWidget
+                :taskGroup="taskGroup"
+                :hasPeerReviewProcesses="hasPeerReviewProcesses"
+                @add-peer-review-process="onShowPeerReviewProcessCreate" />
         </Teleport>
 
         <div v-if="taskGroup" class="cw-tasks-list">
             <ContentBar isContentBar>
                 <template #buttons-left>
                     <router-link :to="{ name: 'task-groups-index' }">
-                        <StudipIcon shape="category-task" :size="24" />
+                        <StudipIcon shape="category-task" :size="24" aria-role="presentation" />
+                        <span class="sr-only">{{ $gettext('Aufgaben') }}</span>
                     </router-link>
                 </template>
                 <template #breadcrumb-list>
@@ -34,6 +38,7 @@
                 :taskGroup="taskGroup"
                 :tasks="tasksByGroup[taskGroup.id]"
                 @add-feedback="onShowAddFeedback"
+                @add-peer-review-process="onShowPeerReviewProcessCreate"
                 @edit-feedback="onShowEditFeedback"
                 @solve-renewal="onShowSolveRenewal"
             />
@@ -57,6 +62,13 @@
             @close="closeDialogs"
         />
 
+        <PeerReviewProcessCreateDialog
+            v-if="showPeerReviewProcessCreate"
+            :taskGroup="taskGroup"
+            @create="onCreatePeerReviewProcess"
+            @close="closeDialogs"
+        />
+
         <RenewalDialog
             v-if="renewalTask"
             :renewalDate="renewalDate"
@@ -65,7 +77,11 @@
             @close="closeDialogs"
         />
 
-        <TaskGroupsAddSolversDialog v-if="showTaskGroupsAddSolversDialog" :taskGroup="taskGroup" @newtask="reloadTasks" />
+        <TaskGroupsAddSolversDialog
+            v-if="showTaskGroupsAddSolversDialog"
+            :taskGroup="taskGroup"
+            @newtask="reloadTasks"
+        />
         <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="taskGroup" />
         <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="taskGroup" />
         <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" />
@@ -79,6 +95,7 @@ import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
 import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
 import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
 import EditFeedbackDialog from './EditFeedbackDialog.vue';
+import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue';
 import RenewalDialog from './RenewalDialog.vue';
 import TaskGroup from './TaskGroup.vue';
 import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
@@ -96,6 +113,7 @@ export default {
         CoursewareTasksActionWidget,
         CoursewareTasksDialogDistribute,
         EditFeedbackDialog,
+        PeerReviewProcessCreateDialog,
         RenewalDialog,
         StudipDate,
         TaskGroup,
@@ -109,6 +127,7 @@ export default {
             currentDialogFeedback: {},
             renewalTask: null,
             showAddFeedbackDialog: false,
+            showPeerReviewProcessCreate: null,
             showEditFeedbackDialog: false,
         };
     },
@@ -116,6 +135,7 @@ export default {
         ...mapGetters({
             context: 'context',
             getTaskGroup: 'courseware-task-groups/byId',
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
             showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog',
             showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog',
             showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog',
@@ -124,6 +144,12 @@ export default {
             tasksLoading: 'courseware-tasks/isLoading',
             userIsTeacher: 'userIsTeacher',
         }),
+        hasPeerReviewProcesses() {
+            return !!this.peerReviewProcesses;
+        },
+        peerReviewProcesses() {
+            return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' });
+        },
         renewalDate() {
             return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date();
         },
@@ -155,6 +181,7 @@ export default {
         ...mapActions({
             companionError: 'companionError',
             companionSuccess: 'companionSuccess',
+            createPeerReviewProcess: 'tasks/createPeerReviewProcess',
             createTaskFeedback: 'createTaskFeedback',
             deleteTaskFeedback: 'deleteTaskFeedback',
             loadAllTasks: 'courseware-tasks/loadAll',
@@ -165,6 +192,7 @@ export default {
         closeDialogs() {
             this.showAddFeedbackDialog = false;
             this.showEditFeedbackDialog = false;
+            this.showPeerReviewProcessCreate = false;
 
             this.currentDialogFeedback = {};
             this.renewalTask = null;
@@ -180,6 +208,12 @@ export default {
             this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback });
             this.closeDialogs();
         },
+        onCreatePeerReviewProcess(options) {
+            this.createPeerReviewProcess({ taskGroup: this.taskGroup, options })
+                .then(() => this.closeDialogs())
+                .then(() => this.loadTaskGroup(this.taskGroup));
+
+        },
         onShowAddFeedback(task) {
             this.currentDialogFeedback = {
                 attributes: { content: '' },
@@ -198,6 +232,9 @@ export default {
             this.currentDialogFeedback = _.cloneDeep(feedback);
             this.showEditFeedbackDialog = true;
         },
+        onShowPeerReviewProcessCreate() {
+            this.showPeerReviewProcessCreate = true;
+        },
         onShowSolveRenewal(task) {
             this.renewalTask = _.cloneDeep(task);
             this.renewalTask.attributes['renewal-date'] = new Date().toISOString();
diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue
index a8dd83221af..2cd6ecd9244 100644
--- a/resources/vue/components/courseware/tasks/RenewalDialog.vue
+++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue
@@ -50,10 +50,7 @@ export default {
         },
         updateRenewal() {
             const date = new Date(this.date);
-            date.setHours(23);
-            date.setMinutes(59);
-            date.setSeconds(59);
-            date.setMilliseconds(999);
+            date.setHours(23, 59, 59, 999);
 
             this.$emit('update', {
                 state: this.state,
diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue
index 122cc7e53ec..a10a8848b4c 100644
--- a/resources/vue/components/courseware/tasks/TaskGroup.vue
+++ b/resources/vue/components/courseware/tasks/TaskGroup.vue
@@ -3,7 +3,9 @@
         <section v-if="tasks.length > 0">
             <table class="default">
                 <caption>
-                    {{ $gettext('Verteilte Aufgaben') }}
+                    {{
+                        $gettext('Verteilte Aufgaben')
+                    }}
                 </caption>
                 <thead>
                     <tr>
@@ -29,6 +31,12 @@
                     />
                 </tbody>
             </table>
+
+            <PeerReviewProcesses
+                :taskGroup="taskGroup"
+                @add-peer-review-process="$emit('add-peer-review-process', taskGroup)"
+                class="cw-task-group-peer-review-processes"
+            />
         </section>
         <div v-else>
             <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" />
@@ -39,10 +47,11 @@
 <script>
 import { mapGetters } from 'vuex';
 import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import PeerReviewProcesses from './TaskGroupPeerReviewProcesses.vue';
 import TaskItem from './TaskGroupTaskItem.vue';
 
 export default {
-    components: { CompanionBox, TaskItem },
+    components: { CompanionBox, PeerReviewProcesses, TaskItem },
     emits: ['add-feedback', 'edit-feedback', 'solve-renewal'],
     props: ['taskGroup', 'tasks'],
     computed: {
@@ -58,3 +67,9 @@ export default {
     },
 };
 </script>
+
+<style scoped>
+.cw-task-group-peer-review-processes {
+    margin-block-start: 3rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue
new file mode 100644
index 00000000000..c8262b4fae1
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue
@@ -0,0 +1,158 @@
+<template>
+    <div>
+        <StudipArticle>
+            <template #title> {{ $gettext('Peer-Review-Prozess') }} </template>
+            <template #body>
+                <CompanionBox
+                    v-if="!hasPeerReviewProcesses"
+                    mood="pointing"
+                    :msgCompanion="$gettext('Für diese Aufgabe wurde noch kein Peer-Review-Prozess aktiviert.')"
+                    :border="false"
+                >
+                    <template #companionActions>
+                        <button class="button" @click="$emit('add-peer-review-process')">
+                            {{ $gettext('Peer-Review-Prozess aktivieren') }}
+                        </button>
+                    </template>
+                </CompanionBox>
+                <ProcessDetail
+                    v-for="process in peerReviewProcesses"
+                    :key="process.id"
+                    :process="process"
+                    @show-assessment-type-editor="onShowAssessmentTypeEditor(process)"
+                    @show-pairing-editor="onShowPairingEditor(process)"
+                    @change-peer-review-process-duration="onShowPeerReviewProcessDuration(process)"
+                    @edit-peer-review-process="onShowPeerReviewProcessEdit(process)"
+                />
+            </template>
+        </StudipArticle>
+
+        <AssessmentTypeEditorDialog
+            v-if="showAssessmentTypeEditor"
+            v-model:show="showAssessmentTypeEditor"
+            :process="selectedProcess"
+            @update="onUpdateAssessmentType"
+        />
+        <PairingEditorDialog v-model:show="showPairingEditor" :process="selectedProcess" @update="onUpdatePairing" />
+        <ProcessEditDialog
+            v-if="showPeerReviewProcessEdit"
+            :process="selectedProcess"
+            @update="onUpdatePeerReviewProcess"
+            @close="showPeerReviewProcessEdit = false"
+        />
+        <ProcessDurationDialog
+            v-model:show="showPeerReviewProcessDuration"
+            :process="selectedProcess"
+            @update="onUpdateDuration"
+        />
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import AssessmentTypeEditorDialog from './peer-review/AssessmentTypeEditorDialog.vue';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import PairingEditorDialog from './peer-review/PairingEditorDialog.vue';
+import ProcessDetail from './peer-review/ProcessDetail.vue';
+import ProcessDurationDialog from './peer-review/ProcessDurationDialog.vue';
+import ProcessEditDialog from './peer-review/ProcessEditDialog.vue';
+import StudipArticle from '../../StudipArticle.vue';
+import { getStatus } from './task-groups-helper.js';
+
+export default {
+    components: {
+        AssessmentTypeEditorDialog,
+        CompanionBox,
+        PairingEditorDialog,
+        ProcessDetail,
+        ProcessDurationDialog,
+        ProcessEditDialog,
+        StudipArticle,
+    },
+    props: ['taskGroup'],
+    data: () => ({
+        selectedProcess: null,
+        showAssessmentTypeEditor: false,
+        showPairingEditor: false,
+        showPeerReviewProcessDuration: false,
+        showPeerReviewProcessEdit: false,
+    }),
+    computed: {
+        ...mapGetters({
+            relatedPeerReviewProcesses: 'courseware-peer-review-processes/related',
+        }),
+        hasPeerReviewProcesses() {
+            return !!this.peerReviewProcesses;
+        },
+        isAfter() {
+            return new Date() > new Date(this.taskGroup.attributes['end-date']);
+        },
+        peerReviewProcesses() {
+            return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' });
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+            replacePairings: 'tasks/replacePairings',
+            updatePeerReviewProcess: 'tasks/updatePeerReviewProcess',
+        }),
+        loadPeerReviews(process) {
+            return this.loadRelatedPeerReviews({
+                parent: process,
+                relationship: 'peer-reviews',
+                options: { include: 'reviewer,task' },
+            });
+        },
+        onShowAssessmentTypeEditor(process) {
+            this.selectedProcess = process;
+            this.showAssessmentTypeEditor = true;
+        },
+        onShowPairingEditor(process) {
+            this.selectedProcess = process;
+            this.showPairingEditor = true;
+        },
+        onShowPeerReviewProcessDuration(process) {
+            this.selectedProcess = process;
+            this.showPeerReviewProcessDuration = true;
+        },
+        onShowPeerReviewProcessEdit(process) {
+            this.selectedProcess = process;
+            this.showPeerReviewProcessEdit = true;
+        },
+        onUpdateAssessmentType(payload) {
+            const configuration = this.selectedProcess.attributes.configuration;
+            configuration.payload = payload;
+
+            this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => {
+                this.selectedProcess = null;
+                this.showAssessmentTypeEditor = false;
+            });
+        },
+        onUpdateDuration(duration) {
+            const configuration = { ...this.selectedProcess.attributes.configuration, duration };
+            this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => {
+                this.selectedProcess = null;
+                this.showPeerReviewProcessDuration = false;
+            });
+        },
+        onUpdatePairing(pairings) {
+            this.replacePairings({ process: this.selectedProcess, pairings })
+                .then(() => this.loadPeerReviews(this.selectedProcess))
+                .then(() => {
+                    this.selectedProcess = null;
+                    this.showPairingEditor = false;
+                })
+                .catch((error) => {
+                    console.error('Could not replace pairings.', error);
+                });
+        },
+        onUpdatePeerReviewProcess({ configuration }) {
+            this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => {
+                this.selectedProcess = null;
+                this.showPeerReviewProcessEdit = false;
+            });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
index 1903f6a30a3..3dde8496b61 100644
--- a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
+++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
@@ -48,10 +48,7 @@ import StudipDate from '../../StudipDate.vue';
 
 const midnight = (_date) => {
     const date = new Date(_date);
-    date.setHours(0);
-    date.setMinutes(0);
-    date.setSeconds(0);
-    date.setMilliseconds(0);
+    date.setHours(0, 0, 0, 0);
     return date;
 };
 
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..1cac7949b71
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue
@@ -0,0 +1,115 @@
+<template>
+    <StudipDialog
+        v-if="show"
+        :title="$gettext('Peer-Review verfassen')"
+        :confirmText="isActive ? $gettext('Speichern') : ''"
+        confirmClass="accept"
+        :closeText="$gettext('Abbrechen')"
+        closeClass="cancel"
+        height="700"
+        width="700"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <CompanionBox
+                v-if="!isActive"
+                mood="sad"
+                :msgCompanion="
+                    $gettext(
+                        'Der Peer-Review-Prozess ist abgeschlossen. Sie können das Peer-Review nicht mehr ändern.'
+                    )
+                "
+            />
+            <component
+                v-bind:is="assessmentComponent"
+                :process="process"
+                :review="review"
+                @answer="onAnswer"
+            ></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import AssessmentTypeForm from './assessment-types/forms/AssessmentTypeForm.vue';
+import AssessmentTypeFreetext from './assessment-types/forms/AssessmentTypeFreetext.vue';
+import AssessmentTypeTable from './assessment-types/forms/AssessmentTypeTable.vue';
+import ResultsTypeForm from './assessment-types/results/Form.vue';
+import ResultsTypeFreetext from './assessment-types/results/Freetext.vue';
+import ResultsTypeTable from './assessment-types/results/Table.vue';
+import { getProcessStatus, ProcessStatus } from './definitions.ts';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    components: {
+        CompanionBox,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    emits: ['update:show'],
+    data: () => ({
+        assessment: {},
+    }),
+    computed: {
+        ...mapGetters({
+            relatedProcess: 'courseware-peer-review-processes/related',
+        }),
+        assessmentComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return this.isActive ? AssessmentTypeForm : ResultsTypeForm;
+                case 'freetext':
+                    return this.isActive ? AssessmentTypeFreetext : ResultsTypeFreetext;
+                case 'table':
+                    return this.isActive ? AssessmentTypeTable : ResultsTypeTable;
+                default:
+                    return null;
+            }
+        },
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        isActive() {
+            return this.process && getProcessStatus(this.process)?.status === ProcessStatus.Active;
+        },
+        process() {
+            return this.relatedProcess({
+                parent: { id: this.review.id, type: this.review.type },
+                relationship: 'process',
+            });
+        },
+    },
+    methods: {
+        ...mapActions({
+            storeAssessment: 'tasks/storeAssessment',
+        }),
+        onAnswer(assessment) {
+            this.assessment = assessment;
+        },
+        onClose() {
+            this.$emit('update:show', false);
+            this.assessment = {};
+        },
+        onConfirm() {
+            this.$emit('update:show', false);
+            this.storeAssessment({ review: this.review, assessment: this.assessment });
+            this.globalEmit('push-system-notification', {
+                type: 'success',
+                message: this.$gettext('Peer-Review gespeichert.'),
+            });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue
new file mode 100644
index 00000000000..92589dfc66d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue
@@ -0,0 +1,65 @@
+<template>
+    <component v-if="editorComponent" v-bind:is="editorComponent" v-model:payload="payload"></component>
+    <CompanionBox v-else :msgCompanion="$gettext('Dieses Bewertungssystem kann nicht konfiguriert werden.')" />
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import EditorForm from './assessment-types/editors/EditorForm.vue';
+import EditorTable from './assessment-types/editors/EditorTable.vue';
+import { ASSESSMENT_TYPES } from './process-configuration';
+
+const getPayload = (configuration) => {
+    const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {};
+    return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload;
+};
+
+const withPayload = (configuration, payload) => {
+    return { ...configuration, payload };
+};
+
+export default {
+    props: {
+        configuration: {
+            type: Object,
+            default: () => ({}),
+        },
+    },
+    components: { CompanionBox },
+    data() {
+        return { localPayload: _.cloneDeep(getPayload(this.configuration)) };
+    },
+    computed: {
+        ...mapGetters({}),
+        editorComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return EditorForm;
+                case 'freetext':
+                    return null;
+                case 'table':
+                    return EditorTable;
+                default:
+                    return null;
+            }
+        },
+        payload: {
+            get() {
+                return getPayload(this.configuration);
+            },
+            set(payload) {
+                this.updatePayload(payload);
+            },
+        },
+    },
+    methods: {
+        updatePayload(payload) {
+            if (!_.isEqual(this.localPayload, payload)) {
+                this.localPayload = payload;
+                this.$emit('update', withPayload(this.configuration, this.localPayload));
+            }
+        },
+    },
+};
+</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..679e0330140
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue
@@ -0,0 +1,84 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Peer-Review-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="payload"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import EditorForm from './assessment-types/editors/EditorForm.vue';
+import EditorTable from './assessment-types/editors/EditorTable.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { ASSESSMENT_TYPES } from './process-configuration';
+
+const getConfiguration = (process) => process?.attributes?.configuration ?? {};
+const getPayload = (process) => {
+    const configuration = getConfiguration(process);
+    const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {};
+    return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload;
+};
+
+export default {
+    components: {
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    emits: ['update:show', 'update'],
+    data() {
+        return { localPayload: _.cloneDeep(getPayload(this.process)) };
+    },
+    computed: {
+        ...mapGetters({}),
+        editorComponent() {
+            switch (getConfiguration(this.process)?.type) {
+                case 'form':
+                    return EditorForm;
+                case 'freetext':
+                    return null;
+                case 'table':
+                    return EditorTable;
+                default:
+                    return null;
+            }
+        },
+        payload: {
+            get() {
+                return getPayload(this.process);
+            },
+            set(payload) {
+                this.localPayload = payload;
+            },
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('update:show', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', _.cloneDeep(this.localPayload));
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
new file mode 100644
index 00000000000..09ff0237c66
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue
@@ -0,0 +1,200 @@
+<template>
+    <div>
+        <form class="default">
+            <div>
+                <label>
+                    {{ $gettext('Lösung von') }}
+                    <select v-model="selectedSubmitter" size="10">
+                        <option v-for="solver in selectableSubmitters" :key="solver.id" :value="solver">
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <label>
+                    {{ $gettext('Peer-Review von') }}
+                    <select v-model="selectedReviewer" size="10">
+                        <option
+                            v-for="solver in selectableReviewers"
+                            :key="solver.id"
+                            :value="solver"
+                            :disabled="solver.id === selectedSubmitter?.id"
+                        >
+                            <span v-if="isUser(solver)">
+                                {{ solver.attributes['formatted-name'] }}
+                            </span>
+                            <span v-if="isStatusGroup(solver)">
+                                {{ solver.attributes.name }}
+                            </span>
+                        </option>
+                        <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option>
+                    </select>
+                </label>
+            </div>
+            <div>
+                <div>
+                    <div>{{ $gettext('Paarungen') }}</div>
+                    <div>
+                        <button
+                            class="button"
+                            type="button"
+                            :disabled="!(selectedSubmitter && selectedReviewer)"
+                            @click="onAdd"
+                        >
+                            <span>{{ $gettext('Paarung hinzufügen') }}</span>
+                        </button>
+                        <table>
+                            <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index">
+                                <td>
+                                    <span v-if="submitter.type === 'users'">
+                                        {{ submitter.attributes['formatted-name'] }}
+                                    </span>
+                                    <span v-if="submitter.type === 'status-groups'">
+                                        {{ submitter.attributes.name }}
+                                    </span>
+                                </td>
+
+                                <td><span>»</span></td>
+                                <td>
+                                    <span v-if="reviewer.type === 'users'">
+                                        {{ reviewer.attributes['formatted-name'] }}
+                                    </span>
+                                    <span v-if="reviewer.type === 'status-groups'">
+                                        {{ reviewer.attributes.name }}
+                                    </span>
+                                </td>
+                                <td>
+                                    <StudipIcon
+                                        name="delete"
+                                        shape="trash"
+                                        :size="20"
+                                        :title="$gettext('Paarung entfernen')"
+                                        @click.prevent="onTrash(index)"
+                                    />
+                                </td>
+                            </tr>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapGetters } from 'vuex';
+import StudipIcon from '../../../StudipIcon.vue';
+
+export default {
+    components: { StudipIcon },
+    props: {
+        pairings: {
+            type: Array,
+            required: true,
+        },
+        solvers: {
+            type: Array,
+            default: () => [],
+        },
+    },
+    emits: ['update:pairings'],
+    data() {
+        return {
+            localPairings: [],
+            selectedSubmitter: null,
+            selectedReviewer: null,
+        };
+    },
+    computed: {
+        selectableReviewers() {
+            const selected = this.localPairings.map(({ reviewer }) => reviewer.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+        selectableSubmitters() {
+            const selected = this.localPairings.map(({ submitter }) => submitter.id);
+            return this.solvers.filter(({ id }) => !selected.includes(id));
+        },
+    },
+    methods: {
+        isStatusGroup(object) {
+            return object.type === 'status-groups';
+        },
+        isUser(object) {
+            return object.type === 'users';
+        },
+        onAdd() {
+            this.localPairings = [
+                ...this.localPairings,
+                {
+                    reviewer: this.selectedReviewer,
+                    submitter: this.selectedSubmitter,
+                },
+            ];
+            this.selectedReviewer = null;
+            this.selectedSubmitter = null;
+        },
+        onTrash(index) {
+            this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)];
+        },
+        resetLocalState() {
+            this.localPairings = [...this.pairings];
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        localPairings(newP, oldP) {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.$emit('update:pairings', [...this.localPairings]);
+            }
+        },
+        pairings() {
+            if (!_.isEqual(this.localPairings, this.pairings)) {
+                this.resetLocalState();
+            }
+        },
+        selectedReviewer() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedSubmitter = null;
+            }
+        },
+        selectedSubmitter() {
+            if (this.selectedReviewer === this.selectedSubmitter) {
+                this.selectedReviewer = null;
+            }
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1rem;
+}
+
+form > :nth-child(-n + 2) {
+    flex-grow: 0;
+    min-width: 15rem;
+}
+
+form > :nth-child(3) {
+    flex-basis: 100%;
+    flex-grow: 1;
+}
+
+tr > :nth-child(2),
+tr > :nth-child(4) {
+    padding-inline: 0.5rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
new file mode 100644
index 00000000000..29aef1c5434
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue
@@ -0,0 +1,102 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Zuordnungen festlegen')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :confirmDisabled="!pairings?.length"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="800"
+        width="800"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <PairingEditor v-if="!storing && pairings" v-model:pairings="pairings" :solvers="solvers" />
+            <ProgressIndicator v-if="storing" :description="$gettext('Zuordnungen werden gespeichert …')" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import PairingEditor from './PairingEditor.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProgressIndicator from '../../../StudipProgressIndicator.vue';
+
+const objId = ({ id, type }) => ({ id, type });
+
+export default {
+    components: {
+        PairingEditor,
+        ProgressIndicator,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    emits: ['update:show', 'update'],
+    data() {
+        return {
+            pairings: [],
+            storing: false,
+        };
+    },
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        reviewPairs() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({
+                reviewer: this.getObject(review.relationships.reviewer.data),
+                submitter: this.getObject(review.relationships.submitter.data),
+            }));
+        },
+        solvers() {
+            return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver));
+        },
+        taskGroup() {
+            return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+        },
+    },
+    methods: {
+        getObject({ type, id }) {
+            return this.$store.getters[`${type}/byId`]({ id });
+        },
+        onClose() {
+            this.$emit('update:show', false);
+        },
+        onConfirm() {
+            if (!this.storing) {
+                this.storing = true;
+                this.$emit('update', this.pairings);
+            }
+        },
+        resetLocalState() {
+            this.storing = false;
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    updated() {
+        this.resetLocalState();
+    },
+    watch: {
+        show() {
+            if (this.show) {
+                this.pairings = this.reviewPairs;
+            }
+        },
+    },
+};
+</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..c88016a99f6
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue
@@ -0,0 +1,66 @@
+<template>
+    <div v-if="peerReviews && peerReviews.length > 0">
+        <table class="default">
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Aufgabe') }}</th>
+                    <th>{{ $gettext('Lösung von') }}</th>
+                    <th>{{ $gettext('Peer-Review von') }}</th>
+                    <th>&nbsp;</th>
+                </tr>
+            </thead>
+            <tbody>
+                <PeerReviewListItem
+                    v-for="review in peerReviews"
+                    :review="review"
+                    :key="review.id"
+                    :process="process"
+                    :task-group="taskGroup"
+                    @show-assessment="onShowAssessment(review)"
+                />
+            </tbody>
+        </table>
+        <PeerReviewResultDialog v-model:show="showPeerReview" v-if="selectedPeerReview" :review="selectedPeerReview" />
+    </div>
+    <div v-else>
+        {{ $gettext("Bisher sind noch keine Peer-Review-Paarungen erstellt worden.") }}
+    </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import PeerReviewListItem from './PeerReviewListItem.vue';
+import PeerReviewResultDialog from './ResultDialog.vue';
+
+export default {
+    components: { PeerReviewListItem, PeerReviewResultDialog },
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+        taskGroup: {
+            type: Object,
+            required: true,
+        },
+    },
+    data: () => ({
+        selectedPeerReview: null,
+        showPeerReview: false,
+    }),
+    computed: {
+        ...mapGetters({
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+        }),
+        peerReviews() {
+            return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+        },
+    },
+    methods: {
+        onShowAssessment(review) {
+            this.selectedPeerReview = review;
+            this.showPeerReview = true;
+        },
+    },
+};
+</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..d24616e3a83
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue
@@ -0,0 +1,134 @@
+<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']"
+                    small
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(submitter)">
+                {{ submitter.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <a v-if="isUser(reviewer)" :href="userProfile(reviewer)">
+                <UserAvatar
+                    :avatar-url="reviewer.meta.avatar.small"
+                    :formatted-name="reviewer.attributes['formatted-name']"
+                    small
+                />
+            </a>
+            <a v-else :href="statusGroupUrl(reviewer)">
+                {{ reviewer.attributes.name }}
+            </a>
+        </td>
+        <td>
+            <template v-if="isPeerReviewAfter">
+                <template v-if="review.attributes.assessment">
+                    <button class="button" @click="onShowAssessment(review)">
+                        {{ $gettext('Peer-Review anzeigen') }}
+                    </button>
+                </template>
+                <template v-else>
+                    {{ $gettext('Kein Peer-Review abgegeben') }}
+                </template>
+            </template>
+            <template v-else>
+                {{ $gettext('Peer-Review sichtbar ab:') }}
+                <StudipDate :date="new Date(process.attributes['review-end'])" />
+            </template>
+        </td>
+    </tr>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import StudipDate from '@/vue/components/StudipDate.vue';
+import UserAvatar from '@/vue/components/StudipUserAvatar.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+import { getProcessStatus, ProcessStatus } from './definitions';
+
+export default {
+    mixins: [taskHelper],
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+        taskGroup: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: { StudipDate, 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 });
+        },
+        isPeerReviewAfter() {
+            return getProcessStatus(this.process)?.status === ProcessStatus.After;
+        },
+        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() {
+            this.$emit('show-assessment');
+        },
+        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..e973a5003a2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue
@@ -0,0 +1,39 @@
+<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>
+import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration';
+
+export default {
+    props: {
+        options: {
+            required: true,
+            type: Object,
+        },
+    },
+    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..1615107cf83
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue
@@ -0,0 +1,131 @@
+<template>
+    <StudipDialog
+        :title="$gettext('Peer-Review-Prozess anlegen')"
+        :confirmText="$gettext('Anlegen')"
+        :confirmDisabled="creating"
+        :closeText="$gettext('Abbrechen')"
+        @close="$emit('close')"
+        @confirm="create"
+        height="800"
+        width="800"
+    >
+        <template #dialogContent>
+            <div v-if="!creating" class="with-sidebar">
+                <div>
+                    <ul>
+                        <li :class="{ active: selectedSlot === 'configuration' }">
+                            <a href="#" @click.prevent="selectedSlot = 'configuration'">
+                                {{ $gettext('Einstellungen') }}
+                            </a>
+                        </li>
+                        <li :class="{ active: selectedSlot === 'assessment' }">
+                            <a href="#" @click.prevent="selectedSlot = 'assessment'">
+                                {{ $gettext('Bewertungssystem') }}
+                            </a>
+                        </li>
+                    </ul>
+                </div>
+                <div v-if="selectedSlot === 'configuration'">
+                    <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" />
+                </div>
+                <div v-if="selectedSlot === 'assessment'">
+                    <AssessmentTypeEditor :configuration="configuration" @update="updateConfiguration" />
+                </div>
+            </div>
+            <div v-if="creating">
+                <CompanionBox :msgCompanion="$gettext('Der Peer-Review-Prozess wird jetzt angelegt.')" />
+            </div>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import AssessmentTypeEditor from './AssessmentTypeEditor.vue';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default {
+    components: { AssessmentTypeEditor, CompanionBox, ProcessCreateForm, StudipDialog },
+    props: ['taskGroup'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+        creating: false,
+        selectedSlot: 'configuration',
+    }),
+    methods: {
+        create() {
+            if (this.creating) {
+                return;
+            }
+            this.creating = true;
+            this.$emit('create', { ...this.configuration });
+        },
+        updateConfiguration(configuration) {
+            this.changed = true;
+            this.configuration = configuration;
+        },
+    },
+};
+</script>
+
+<style scoped lang="scss">
+.with-sidebar {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1em;
+}
+
+.with-sidebar > :first-child {
+    flex-grow: 1;
+}
+
+.with-sidebar > :last-child {
+    flex-basis: 0;
+    flex-grow: 999;
+    min-inline-size: 50%;
+}
+
+.with-sidebar > :first-child {
+    ul {
+        list-style: none;
+        padding: 0;
+        width: 12em;
+
+        > li:has(> a):not(:last-child) {
+            border-bottom: solid thin var(--color--sidebar-divider);
+        }
+
+        > li {
+            padding-block: 2px;
+            padding-inline-start: 5px;
+
+            a {
+                display: block;
+                line-height: 17px;
+                padding-block: 4px;
+                padding-inline: 0px;
+                word-wrap: break-word;
+            }
+
+            &.active {
+                background-color: var(--color--sidebar-active);
+                border-left: solid 4px var(--color--sidebar-marker-active);
+                margin-left: -4px;
+                padding-left: 1px;
+
+                a {
+                    color: var(--black);
+                    padding-left: 4px;
+                }
+            }
+        }
+
+        > li.active {
+            background-color: var(--color--sidebar-active);
+        }
+    }
+}
+</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..6685baa6963
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue
@@ -0,0 +1,319 @@
+<template>
+    <form class="default" @submit.prevent="">
+        <fieldset class="select_configuration_set">
+            <template v-for="(configurationSet, index) in configurationSets" :key="`configuration-set-${index}`">
+                <input
+                    :aria-description="'todo'"
+                    :checked="selectedConfigurationSet === index"
+                    :id="`configuration_set_${index}`"
+                    :value="index"
+                    name="selected_configuration_set"
+                    type="radio"
+                />
+                <label @click="selectConfigurationSet(index)">
+                    <div class="icon">
+                        <studip-icon
+                            :shape="`radiobutton-${selectedConfigurationSet === index ? 'checked' : 'unchecked'}`"
+                            :size="24"
+                        />
+                    </div>
+                    <div class="text">
+                        {{ configurationSet.name }}
+                    </div>
+                    <studip-icon shape="arr_1down" :size="24" class="arrow" />
+                    <studip-icon shape="check-circle" :size="24" class="check" />
+                </label>
+                <div>
+                    <PeerReviewProcessConfiguration :options="configurationSet.configuration" />
+                </div>
+            </template>
+
+            <input
+                :aria-description="'todo'"
+                :checked="selectedConfigurationSet === null"
+                id="configuration_set_custom"
+                value="custom"
+                name="selected_configuration_set"
+                type="radio"
+            />
+            <label @click="selectConfigurationSet(null)">
+                <div class="icon">
+                    <studip-icon
+                        :shape="`radiobutton-${selectedConfigurationSet === null ? 'checked' : 'unchecked'}`"
+                        :size="24"
+                    />
+                </div>
+                <div class="text">
+                    {{ $gettext('Eigene Einstellungen') }}
+                </div>
+                <studip-icon shape="arr_1down" :size="24" class="arrow" />
+                <studip-icon shape="check-circle" :size="24" class="check" />
+            </label>
+            <div class="peer-review-process-create-form-custom-configuration">
+                <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="update"
+                            >
+                                <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="update"
+                            >
+                                <option v-for="i in 21" :key="i">{{ i }}</option>
+                            </select>
+                        </LabelRequired>
+                    </div>
+
+                    <div class="formpart">
+                        <LabelRequired
+                            :id="`peer-review-process-create-form-${uid}-type`"
+                            :label="$gettext('Art des Reviews:')"
+                        >
+                            <select
+                                v-model="localConfiguration.type"
+                                :id="`peer-review-process-create-form-${uid}-type`"
+                                @change="onChangeType"
+                            >
+                                <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key">
+                                    {{ short }}
+                                </option>
+                            </select>
+                        </LabelRequired>
+                    </div>
+
+                    <div class="formpart">
+                        <LabelRequired
+                            :id="`peer-true-process-create-form-${uid}-anonymous`"
+                            :label="$gettext('Review-Paarungen')"
+                        >
+                            <select
+                                v-model="localConfiguration.automaticPairing"
+                                :id="`peer-review-process-create-form-${uid}-automatic-pairing`"
+                                @change="update"
+                            >
+                                <option :value="true">{{ $gettext('Zufall') }}</option>
+                                <option :value="false">{{ $gettext('Manuell') }}</option>
+                            </select>
+                        </LabelRequired>
+                    </div>
+                </div>
+            </div>
+        </fieldset>
+    </form>
+</template>
+
+<script>
+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 {
+    components: { LabelRequired, PeerReviewProcessConfiguration },
+    props: {
+        configuration: {
+            required: true,
+            type: Object,
+        },
+        custom: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    data() {
+        return {
+            localConfiguration: { ...this.configuration },
+            selectedConfigurationSet: 0,
+            uid: nextId++,
+        };
+    },
+    computed: {
+        reviewTypes: () => ASSESSMENT_TYPES,
+        configurationSets: () => CONFIGURATION_SETS,
+    },
+    methods: {
+        customizeConfiguration() {
+            this.update();
+        },
+        findSelectedConfigurationSet() {
+            const index = this.configurationSets.findIndex(({ configuration }) =>
+                _.isEqual(this.configuration, configuration),
+            );
+            this.selectedConfigurationSet = index === -1 ? null : index;
+        },
+        onChangeType() {
+            this.localConfiguration.payload =
+                this.localConfiguration.type === this.configuration.type
+                    ? this.configuration.payload
+                    : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload;
+            this.customizeConfiguration();
+        },
+        resetData() {
+            this.localConfiguration = { ...this.configuration };
+            // this.findSelectedConfigurationSet();
+        },
+        selectConfigurationSet(configurationSetIndex) {
+            this.selectedConfigurationSet = configurationSetIndex;
+            if (configurationSetIndex in CONFIGURATION_SETS) {
+                this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration;
+            }
+            this.update();
+        },
+        update() {
+            this.$emit('update', this.localConfiguration);
+        },
+    },
+    mounted() {
+        this.findSelectedConfigurationSet();
+    },
+    watch: {
+        configuration() {
+            this.localConfiguration = { ...this.configuration };
+        },
+    },
+};
+</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;
+}
+
+fieldset.select_configuration_set {
+    border: none;
+    padding: 0;
+    margin: 0;
+
+    > :not(legend) {
+        margin: 0;
+    }
+
+    > input[type='radio'] {
+        opacity: 0;
+        position: absolute;
+        &:focus + label {
+            outline: auto;
+        }
+    }
+    > label {
+        cursor: pointer;
+        border: 1px solid var(--content-color-40);
+        transition: background-color var(--transition-duration);
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 0 10px 0;
+        margin: 0;
+        border-top: none;
+        > .text {
+            width: 100%;
+            margin-left: 10px;
+        }
+        > .check {
+            display: none;
+        }
+
+        > .icon {
+            margin-top: 6px;
+        }
+    }
+    > label:first-of-type {
+        border-top: 1px solid var(--content-color-40);
+    }
+    > div {
+        border: 1px solid var(--content-color-40);
+        border-top: none;
+        display: none;
+        padding: 10px;
+    }
+    > input[type='radio']:checked + label {
+        background-color: var(--content-color-20);
+        transition: background-color var(--transition-duration);
+        > .arrow {
+            display: none;
+        }
+        > .check {
+            display: inline-block;
+        }
+    }
+    > input[type='radio']:checked + label + div {
+        display: block;
+        > * {
+            animation-duration: 400ms;
+            animation-name: terms_of_use_fadein;
+        }
+    }
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue
new file mode 100644
index 00000000000..a33cad2ba7d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue
@@ -0,0 +1,217 @@
+<template>
+    <div>
+        <CompanionBox
+            v-if="isActive"
+            :msgCompanion="
+                $gettext(
+                    'Der Peer-Review-Prozess hat bereits begonnen. Die Einstellungen können bis auf die Bearbeitungsdauer nicht geändert werden.',
+                )
+            "
+        />
+
+        <section>
+            <article>
+                <header>
+                    <h4>{{ $gettext('Status') }}</h4>
+                </header>
+                <div class="cw-peer-review-processes-status">
+                    <ProcessStatus :process="process" />
+                    <span>{{ processStatus.description }}</span>
+                </div>
+                <div class="cw-peer-review-processes-duration">
+                    <span>{{ $gettext('Bearbeitungszeit:') }}</span>
+                    <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                    <div v-if="canChangeDurationOnly">
+                        <button class="button" @click="$emit('change-peer-review-process-duration')">
+                            {{ $gettext('Bearbeitungszeit verlängern') }}
+                        </button>
+                    </div>
+                </div>
+                <div v-if="isBefore">
+                    <div>
+                        {{
+                            isAutomaticPairing
+                                ? $gettext(
+                                      'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.',
+                                  )
+                                : $gettext(
+                                      'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.',
+                                  )
+                        }}
+                    </div>
+
+                    <button v-if="!isAutomaticPairing" class="button" @click="$emit('show-pairing-editor')">
+                        {{ $gettext('Paarungen manuell festlegen') }}
+                    </button>
+                </div>
+            </article>
+            <article>
+                <header>
+                    <h4>{{ $gettext('Einstellungen') }}</h4>
+                </header>
+                <div>
+                    <ProcessConfiguration :options="configuration" />
+                </div>
+                <div>
+                    <button
+                        v-if="canChangeConfiguration"
+                        class="button"
+                        @click="$emit('edit-peer-review-process')"
+                    >
+                        {{ $gettext('Einstellungen ändern') }}
+                    </button>
+                    <button
+                        v-if="configuration.type === 'form' || configuration.type === 'table'"
+                        class="button"
+                        @click="$emit('show-assessment-type-editor')"
+                        :disabled="!canChangeConfiguration"
+                    >
+                        {{ $gettext('Bewertungssystem konfigurieren') }}
+                    </button>
+                </div>
+            </article>
+
+            <article>
+                <header>
+                    <h4>{{ $gettext('Peer-Review-Paarungen') }}</h4>
+                </header>
+                <PeerReviewList :process="process" :task-group="taskGroup" />
+            </article>
+        </section>
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import PeerReviewList from './PeerReviewList.vue';
+import ProcessConfiguration from './ProcessConfiguration.vue';
+import ProcessStatus from './ProcessStatus.vue';
+import StudipDate from '../../../StudipDate.vue';
+import { getProcessStatus, ProcessStatus as Status } from './definitions';
+
+export default {
+    components: {
+        CompanionBox,
+        PeerReviewList,
+        ProcessConfiguration,
+        ProcessStatus,
+        StudipDate,
+    },
+    props: {
+        process: {
+            type: Object,
+            required: true,
+        },
+    },
+    emits: [
+        'show-assessment-type-editor',
+        'show-pairing-editor',
+        'change-peer-review-process-duration',
+        'edit-peer-review-process',
+    ],
+    data: () => ({}),
+    computed: {
+        ...mapGetters({
+            getProcess: 'courseware-peer-review-processes/byId',
+            relatedPeerReviews: 'courseware-peer-reviews/related',
+            relatedTasks: 'courseware-tasks/related',
+            relatedTaskGroups: 'courseware-task-groups/related',
+            relatedUsers: 'users/related',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        canChangeConfiguration() {
+            return this.isBefore;
+        },
+        canChangeDurationOnly() {
+            return this.processStatus.status === Status.Active;
+        },
+        configuration() {
+            return this.process.attributes['configuration'];
+        },
+        endDate() {
+            return new Date(this.process.attributes['review-end']);
+        },
+        isActive() {
+            return this.processStatus.status === Status.Active;
+        },
+        isAfter() {
+            return this.processStatus.status === Status.After;
+        },
+        isBefore() {
+            return this.processStatus.status === Status.Before;
+        },
+        isAutomaticPairing() {
+            return this.configuration.automaticPairing;
+        },
+        owner() {
+            return this.relatedUsers({ parent: this.process, relationship: 'owner' });
+        },
+        peerReviews() {
+            const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' });
+            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: {
+        ...mapActions({
+            loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated',
+        }),
+        loadPeerReviews() {
+            return this.loadRelatedPeerReviews({
+                parent: this.process,
+                relationship: 'peer-reviews',
+                options: { include: 'reviewer,task' },
+            });
+        },
+    },
+    async mounted() {
+        await this.loadPeerReviews();
+    },
+};
+</script>
+
+<style scoped>
+.cw-peer-review-processes-status {
+    display: flex;
+    gap: 0.25rem;
+}
+
+section {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2em;
+}
+
+section > article:last-child {
+    grow: 1;
+    flex-basis: 100%;
+}
+
+section > article:nth-child(-n + 2) {
+    grow: 0;
+    flex-basis: 32em;
+}
+
+section > article:first-child {
+    display: flex;
+    gap: 1em;
+    flex-direction: column;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
new file mode 100644
index 00000000000..46eae8f449e
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue
@@ -0,0 +1,116 @@
+<template>
+    <StudipDialog
+        v-if="show && process"
+        :title="$gettext('Bearbeitungszeit ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <p>
+                    {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }})
+                </p>
+                <div class="formpart">
+                    <LabelRequired
+                        :id="`peer-review-process-${uid}`"
+                        :label="$gettext('Bearbeitungszeit verlängern bis zum:')"
+                    />
+                    <input
+                        :id="`peer-review-process-${uid}`"
+                        name="end-date"
+                        type="date"
+                        v-model="localEndDate"
+                        :min="endDateString"
+                        class="size-l"
+                        required
+                    />
+                    <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div>
+                </div>
+            </form>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import LabelRequired from '../../../forms/LabelRequired.vue';
+import StudipDate from '../../../StudipDate.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+
+const midnight = (_date) => {
+    const date = new Date(_date);
+    date.setHours(0, 0, 0, 0);
+    return date;
+};
+
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
+let nextUid = 0;
+
+export default {
+    components: {
+        LabelRequired,
+        StudipDate,
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        process: {
+            type: Object,
+            default: null,
+        },
+    },
+    emits: ['update:show', 'update'],
+    data: () => ({ localEndDate: null, uid: nextUid++ }),
+    computed: {
+        configuration() {
+            return this.process?.attributes?.configuration ?? {};
+        },
+        endDate() {
+            return midnight(this.process?.attributes?.['review-end'] ?? new Date());
+        },
+        endDateString() {
+            return dateString(this.endDate);
+        },
+        newDuration() {
+            return this.localEndDate
+                ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24))
+                : 0;
+        },
+        oldDuration() {
+            return this.configuration.duration ?? '??';
+        },
+        startDate() {
+            return midnight(this.process.attributes['review-start']);
+        },
+    },
+    methods: {
+        onClose() {
+            this.$emit('update:show', false);
+        },
+        onConfirm(...args) {
+            this.$emit('update', this.newDuration);
+        },
+        resetLocalVars() {
+            this.localEndDate = dateString(this.endDate ?? new Date());
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        process() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
new file mode 100644
index 00000000000..751f9523641
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue
@@ -0,0 +1,64 @@
+<template>
+    <StudipDialog
+        :title="title"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :confirmDisabled="!changed"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="600"
+        width="800"
+        @close="$emit('close')"
+        @confirm="confirm"
+    >
+        <template #dialogContent>
+            <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" />
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext';
+import StudipDialog from '../../../StudipDialog.vue';
+import ProcessCreateForm from './ProcessCreateForm.vue';
+import { defaultConfiguration, ProcessConfiguration } from './process-configuration';
+
+export default {
+    components: { ProcessCreateForm, StudipDialog },
+    props: ['process'],
+    data: () => ({
+        changed: false,
+        configuration: defaultConfiguration(),
+    }),
+    computed: {
+        ...mapGetters({
+            relatedTaskGroups: 'courseware-task-groups/related',
+        }),
+        title() {
+            const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' });
+            return $gettextInterpolate($gettext('Peer-Review-Prozess anlegen zur Aufgabe "%{title}"'), {
+                title: taskGroup.attributes.title,
+            });
+        },
+    },
+    methods: {
+        confirm() {
+            this.$emit('update', {
+                process: this.process,
+                configuration: { ...this.configuration },
+            });
+        },
+        updateConfiguration(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..666514d8aa7
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue
@@ -0,0 +1,47 @@
+<template>
+    <span class="peer-review-process-status" v-if="!filter || status.status === filter">
+        <StudipIcon
+            v-if="status.shape !== undefined"
+            :shape="status.shape"
+            :role="status.role"
+            :title="status.description"
+            aria-hidden="true"
+        />
+        <span :class="{'sr-only': !description }">{{ status.description }}</span>
+    </span>
+</template>
+<script>
+import StudipIcon from '../../../StudipIcon.vue';
+import { getProcessStatus, ProcessStatus } from './definitions';
+
+export default {
+    components: { StudipIcon },
+    props: {
+        description: {
+            type: Boolean,
+            default: false,
+        },
+        filter: {
+            type: String,
+            default: null,
+        },
+        process: {
+            type: Object,
+            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..59c80ffa769
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ProcessesList.vue
@@ -0,0 +1,174 @@
+<template>
+    <div class="cw-peer-review-processes-wrapper" v-if="!userIsTeacher">
+        <table class="default" v-if="peerReviews.length">
+            <caption>
+                {{ $gettext('Peer-Reviews') }}
+            </caption>
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Status') }}</th>
+                    <th>{{ $gettext('Bearbeitungszeit') }}</th>
+                    <th>{{ $gettext('Aufgabe') }}</th>
+                    <th>
+                        {{ $gettext('Erhaltene Peer-Reviews') }}
+                    </th>
+                    <th>
+                        {{ $gettext('Gegebene Peer-Reviews') }}
+                    </th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="process in processes" :key="process.id">
+                    <td>
+                        <ProcessStatusIcon :process="process" />
+                    </td>
+                    <td>
+                        <StudipDate :date="new Date(process.attributes['review-start'])" />
+                        -
+                        <StudipDate :date="new Date(process.attributes['review-end'])" />
+                    </td>
+                    <td>
+                        {{ taskGroups[process.id].attributes.title }}
+                    </td>
+                    <td>
+                        <div v-for="review in peerReviewsForMe(process)" :key="review.id">
+                            <template v-if="isPeerReviewProcessAfter(process)">
+                                <template v-if="review.attributes.assessment">
+                                    <a :href="elementUrls[review.id]" class="button">
+                                        {{ $gettext('Erhaltenes Peer-Review anzeigen') }}
+                                    </a>
+                                </template>
+                            </template>
+                            <template v-else>
+                                <button class="button" disabled>
+                                    {{ $gettext('Peer-Review noch nicht sichtbar') }}
+                                </button>
+                            </template>
+                        </div>
+                    </td>
+                    <td>
+                        <div v-for="review in peerReviewsFromMe(process)" :key="review.id">
+                            <template v-if="isPeerReviewProcessActive(process)">
+                                <a :href="elementUrls[review.id]" class="button">
+                                    {{ $gettext('Peer-Review geben') }}
+                                </a>
+                            </template>
+                            <template v-else-if="review.attributes.assessment">
+                                <a :href="elementUrls[review.id]" class="button">
+                                    {{ $gettext('Gegebenes Peer-Review anzeigen') }}
+                                </a>
+                            </template>
+                        </div>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <CompanionBox
+            v-else-if="!loading"
+            mood="sad"
+            :msgCompanion="$gettext('Sie haben noch keine Peer-Reviews erhalten oder gegeben.')"
+        />
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import CompanionBox from '../../layouts/CoursewareCompanionBox.vue';
+import ProcessStatusIcon from './ProcessStatus.vue';
+import StudipDate from '../../../StudipDate.vue';
+import taskHelper from '../../../../mixins/courseware/task-helper.js';
+import { getProcessStatus, ProcessStatus } from './definitions';
+
+export default {
+    components: {
+        CompanionBox,
+        ProcessStatusIcon,
+        StudipDate,
+    },
+    mixins: [taskHelper],
+    data: () => ({
+        loading: true,
+    }),
+    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',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        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 _.reverse(
+                _.sortBy(
+                    Object.values(
+                        this.peerReviews.reduce((memo, review) => {
+                            const process = this.relatedPeerReviewProcesses({
+                                parent: review,
+                                relationship: 'process',
+                            });
+                            memo[process.id] = process;
+                            return memo;
+                        }, {})
+                    ),
+                    ['attributes.chdate']
+                )
+            );
+        },
+        taskGroups() {
+            return Object.values(this.processes).reduce((memo, process) => {
+                memo[process.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',
+        }),
+        isPeerReviewProcessActive(process) {
+            return getProcessStatus(process)?.status === ProcessStatus.Active;
+        },
+        isPeerReviewProcessAfter(process) {
+            return getProcessStatus(process)?.status === ProcessStatus.After;
+        },
+        reviewsOf(process) {
+            return this.peerReviews.filter((review) => review.relationships.process.data.id === process.id);
+        },
+        peerReviewsFromMe(process) {
+            return this.reviewsOf(process).filter((process) => process.attributes['is-reviewer']);
+        },
+        peerReviewsForMe(process) {
+            return this.reviewsOf(process).filter((process) => process.attributes['is-submitter']);
+        },
+    },
+    mounted() {
+        const parent = { type: 'courses', id: this.context.id };
+        const relationship = 'courseware-peer-reviews';
+        const options = {
+            include: 'process,task.structural-element,task.task-group,reviewer,submitter',
+        };
+        this.loadRelatedPeerReviews({ parent, relationship, options }).then(() => (this.loading = false));
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue
new file mode 100644
index 00000000000..5dc28df0474
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue
@@ -0,0 +1,71 @@
+<template>
+    <StudipDialog
+        v-if="show"
+        :title="$gettext('Peer-Review einsehen')"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="700"
+        width="610"
+        @close="onClose"
+    >
+        <template #dialogContent>
+            <component v-bind:is="assessmentComponent" :process="process" :review="review"></component>
+        </template>
+    </StudipDialog>
+</template>
+
+<script>
+import ResultForm from './assessment-types/results/Form.vue';
+import ResultFreetext from './assessment-types/results/Freetext.vue';
+import ResultTable from './assessment-types/results/Table.vue';
+import StudipDialog from '../../../StudipDialog.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    components: {
+        StudipDialog,
+    },
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    emits: ['update:show'],
+    computed: {
+        ...mapGetters({
+            relatedProcess: 'courseware-peer-review-processes/related',
+        }),
+        assessmentComponent() {
+            switch (this.configuration?.type) {
+                case 'form':
+                    return ResultForm;
+                case 'freetext':
+                    return ResultFreetext;
+                case 'table':
+                    return ResultTable;
+                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('update:show', false);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue
new file mode 100644
index 00000000000..f6995b1cc1d
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue
@@ -0,0 +1,149 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor">
+            <form class="default studipform">
+                <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable>
+                    <template #title="{ isOpen }">
+                        <template v-if="isOpen">
+                            {{
+                                $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), {
+                                    index: index + 1,
+                                    text: criterium.text,
+                                })
+                            }}
+                        </template>
+                        <template v-else>
+                            {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }}
+                        </template>
+                    </template>
+                    <template #titleplus>
+                        <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" />
+                    </template>
+                    <template #body>
+                        <div class="formpart criterium-text">
+                            <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" />
+                            <input
+                                :id="`editor-form-text-${index}`"
+                                type="text"
+                                v-model="criterium.text"
+                                required
+                                aria-required="true"
+                            />
+                        </div>
+                        <div class="formpart criterium-description">
+                            <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" />
+                            <textarea
+                                :id="`editor-form-description-${index}`"
+                                v-model="criterium.description"
+                                required
+                                aria-required="true"
+                            ></textarea>
+                        </div>
+                    </template>
+                </StudipArticle>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview">
+            <article>
+                <section v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                    <strong>{{ criterium.text }}</strong>
+                    <p>{{ criterium.description }}</p>
+                    <textarea disabled />
+                </section>
+            </article>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script>
+import StudipActionMenu from '../../../../../StudipActionMenu.vue';
+import StudipArticle from '../../../../../StudipArticle.vue';
+import LabelRequired from '../../../../../forms/LabelRequired.vue';
+import CoursewareTab from '../../../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue';
+import { EditorFormCriterium, FormAssessmentPayload } from '../../process-configuration';
+
+export default {
+    components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle },
+    props: {
+        payload: {
+            type: Object
+        },
+    },
+    emits: ['update:payload'],
+    data: () => ({ localCriteria: [] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        actionItems(index) {
+            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) {
+            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('update:payload', { 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/assessment-types/editors/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue
new file mode 100644
index 00000000000..96511ad4f78
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue
@@ -0,0 +1,138 @@
+<template>
+    <CoursewareTabs>
+        <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor">
+            <form class="studip studipform">
+                <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index">
+                    <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" />
+                    <input
+                        :id="`editor-table-text-${index}`"
+                        type="text"
+                        v-model="criterium.text"
+                        required
+                        aria-required="true"
+                    />
+                    <StudipIcon
+                        :disabled="criteria.length === 1"
+                        name="delete"
+                        shape="trash"
+                        :size="20"
+                        :title="$gettext('Kriterium entfernen')"
+                        @click.prevent="removeLine(index)"
+                    />
+                </div>
+                <div class="formpart">
+                    <button class="button add" type="button" @click="addLine">
+                        <span>{{ $gettext('Kriterium hinzufügen') }}</span>
+                    </button>
+                </div>
+            </form>
+        </CoursewareTab>
+        <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview">
+            <table class="default">
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Kriterien') }}</th>
+                        <th>{{ $gettext('Bewertung') }}</th>
+                        <th>{{ $gettext('Kommentar') }}</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index">
+                        <td>{{ criterium.text }}</td>
+                        <td>
+                            <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text">
+                                <input name="rating" type="radio" disabled />
+                                {{ text }}
+                            </label>
+                        </td>
+                        <td>
+                            <textarea disabled />
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </CoursewareTab>
+    </CoursewareTabs>
+</template>
+<script>
+import LabelRequired from '../../../../../forms/LabelRequired.vue';
+import CoursewareTab from '../../../../layouts/CoursewareTab.vue';
+import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue';
+import { EditorTableCriterium, TableAssessmentPayload } from '../../process-configuration';
+
+export default {
+    components: { CoursewareTab, CoursewareTabs, LabelRequired },
+    props: {
+        payload: {
+            type: Object,
+        },
+    },
+    emits: ['update:payload'],
+    data: () => ({ localCriteria: [] }),
+    computed: {
+        criteria() {
+            return this.payload.criteria;
+        },
+        nonEmptyCriteria() {
+            return this.localCriteria.filter(({ text }) => text.trim().length);
+        },
+    },
+    methods: {
+        addLine() {
+            this.localCriteria.push({ text: '' });
+        },
+        removeLine(lineNumber) {
+            this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber);
+        },
+        resetLocalState() {
+            this.localCriteria = this.criteria.map(({ text }) => ({ text }));
+        },
+    },
+    mounted() {
+        this.resetLocalState();
+    },
+    watch: {
+        payload() {
+            this.resetLocalState();
+        },
+        localCriteria: {
+            handler() {
+                this.$emit('update:payload', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) });
+            },
+            deep: true,
+        },
+    },
+};
+</script>
+
+<style scoped>
+form button.trash {
+    min-width: 2em;
+    width: 2em;
+}
+form input {
+    flex-grow: 1;
+    height: 1.7em;
+    max-width: 48em;
+}
+
+form .formpart {
+    display: flex;
+    align-items: center;
+    gap: 1em;
+}
+
+.formpart {
+    margin-block-start: 0.75em;
+}
+
+.cw-peer-review-editor-table--preview label {
+    display: block;
+    white-space: nowrap;
+}
+
+.cw-peer-review-editor-table-editor input[name="delete"] {
+    flex-grow: 0;
+    padding-inline: 0.7em;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue
new file mode 100644
index 00000000000..283031eeb59
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue
@@ -0,0 +1,70 @@
+<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"
+                    :disabled="disabled"
+                    v-model="answers[index]"
+                    @change="changeAnswers" />
+            </div>
+        </form>
+    </article>
+</template>
+<script>
+import LabelRequired from '../../../../../forms/LabelRequired.vue';
+
+export default {
+    components: { LabelRequired },
+    props: {
+        disabled: {
+            type: Boolean,
+            default: false,
+        },
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: this.review.attributes.assessment?.answers ?? [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            const answers = this.criteria.map((_, index) => this.answers[index] ?? '');
+            this.$emit('answer', { answers });
+        },
+    },
+};
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue
new file mode 100644
index 00000000000..06cc779323a
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue
@@ -0,0 +1,64 @@
+<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"
+                    rows="17"
+                    :disabled="disabled"
+                    v-model="answer"
+                    @change="changeAnswer" />
+            </div>
+        </form>
+    </article>
+</template>
+<script>
+import LabelRequired from '../../../../../forms/LabelRequired.vue';
+
+export default {
+    components: { LabelRequired },
+    props: {
+        disabled: {
+            type: Boolean,
+            default: false,
+        },
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answer: this.review.attributes.assessment?.answer ?? '',
+        };
+    },
+    methods: {
+        changeAnswer() {
+            const answer = this.answer ?? '';
+            this.$emit('answer', { answer });
+        },
+    },
+};
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 1rem;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue
new file mode 100644
index 00000000000..e24a5150c80
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue
@@ -0,0 +1,114 @@
+<template>
+    <article>
+        <form class="default studipform">
+            <div class="formpart" v-for="(criterium, index) in criteria" :key="index">
+                <fieldset>
+                    <legend>
+                        {{ criterium.text }}
+                    </legend>
+                    <section>
+                        <div>
+                            <label v-for="(text, rating) in ratingLevels" :key="text"
+                                ><input
+                                    :disabled="disabled"
+                                    v-model="answers[index].rating"
+                                    :name="`rating-${index}`"
+                                    type="radio"
+                                    :value="rating + 1"
+                                    @change="changeAnswers"
+                                />{{ text }}</label
+                            >
+                        </div>
+                        <label :for="`assessment-type-table-${index}`">
+                            {{ $gettext('Begründung') }}
+                        </label>
+                        <textarea
+                            :id="`assessment-type-table-${index}`"
+                            :disabled="disabled"
+                            v-model="answers[index].text"
+                            @change="changeAnswers"
+                        />
+                    </section>
+                </fieldset>
+            </div>
+        </form>
+    </article>
+</template>
+<script>
+import { $gettext } from '../../../../../../../assets/javascripts/lib/gettext';
+
+const emptyAssessment = (criteria) => {
+    return {
+        answers: criteria.map((_) => ({ text: '', rating: 0 })),
+    };
+};
+
+export default {
+    props: {
+        disabled: {
+            type: Boolean,
+            default: false,
+        },
+        process: {
+            type: Object,
+            required: true,
+        },
+        review: {
+            type: Object,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            answers: [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+        ratingLevels() {
+            return [$gettext('gut'), $gettext('ok'), $gettext('schwach')];
+        },
+    },
+    methods: {
+        changeAnswers() {
+            this.$emit('answer', { answers: this.answers });
+        },
+    },
+    beforeMount() {
+        if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) {
+            this.answers = this.review.attributes.assessment.answers;
+        } else {
+            this.answers = emptyAssessment(this.criteria).answers;
+        }
+    },
+};
+</script>
+
+<style scoped>
+textarea {
+    min-height: 5em;
+    max-width: 48em;
+    width: 100%;
+}
+
+.formpart + .formpart {
+    margin-block-start: 2rem;
+}
+
+form.default .formpart section {
+    padding-block-start: 0;
+}
+
+.formpart section div {
+    display: flex;
+    gap: 1em;
+}
+
+.formpart section label {
+    display: inline-block;
+    white-space: nowrap;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue
new file mode 100644
index 00000000000..783259ad1f2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue
@@ -0,0 +1,54 @@
+<template>
+    <article>
+        <section v-for="(criterium, index) in criteria" :key="index" class="criterium">
+            <header>{{ criterium.text }}</header>
+
+            <p class="criterium-description">{{ criterium.description }}</p>
+
+            <p class="criterium-text">{{ answers[index] }}</p>
+        </section>
+    </article>
+</template>
+
+<script>
+export default {
+    props: {
+        process: { type: Object, required: true },
+        review: { type: Object, required: true },
+    },
+    data() {
+        return {
+            answers: this.review.attributes.assessment?.answers ?? [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+    },
+};
+</script>
+
+<style scoped>
+article {
+    padding-inline: 1rem;
+}
+
+.criterium + .criterium {
+    margin-block-start: 2rem;
+}
+
+.criterium header {
+    font-weight: bold;
+    margin-block: 1em;
+}
+
+.criterium-description {
+    font-style: italic;
+}
+
+.criterium-text {
+    line-height: 1.65;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue
new file mode 100644
index 00000000000..c3c71ddfc9e
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue
@@ -0,0 +1,42 @@
+<template>
+    <article>
+        <section class="criterium">
+            <header>{{ $gettext('Bewertung') }}</header>
+
+            <p class="criterium-text">{{ answer }}</p>
+        </section>
+    </article>
+</template>
+
+<script>
+export default {
+    props: {
+        process: { type: Object, required: true },
+        review: { type: Object, required: true },
+    },
+    data() {
+        return {
+            answer: this.review.attributes.assessment?.answer ?? '',
+        };
+    },
+};
+</script>
+
+<style scoped>
+article {
+    padding-inline: 1em;
+}
+
+.criterium {
+    margin-block-end: 2em;
+}
+
+.criterium header {
+    font-weight: bold;
+    margin-block: 1em;
+}
+
+.criterium-text {
+    line-height: 1.65;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue
new file mode 100644
index 00000000000..99d54e16c40
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue
@@ -0,0 +1,71 @@
+<template>
+    <article>
+        <section v-for="(criterium, index) in criteria" :key="index" class="criterium">
+            <header>{{ criterium.text }}</header>
+
+            <div class="criterium-rating">
+                <div>{{ $gettext('Bewertung') }}</div>
+                <p>{{ ratingLevels[answers[index].rating - 1] }}</p>
+            </div>
+
+            <p class="criterium-text">{{ answers[index].text }}</p>
+        </section>
+    </article>
+</template>
+
+<script>
+const emptyAssessment = (criteria) => ({
+    answers: criteria.map((_) => ({ text: '', rating: 0 })),
+});
+
+export default {
+    props: {
+        process: { type: Object, required: true },
+        review: { type: Object, required: true },
+    },
+    data() {
+        return {
+            answers: [],
+        };
+    },
+    computed: {
+        criteria() {
+            const payload = this.process.attributes.configuration.payload;
+            return payload.criteria ?? [];
+        },
+        ratingLevels() {
+            return [this.$gettext('gut'), this.$gettext('ok'), this.$gettext('schwach')];
+        },
+    },
+    beforeMount() {
+        if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) {
+            this.answers = this.review.attributes.assessment.answers;
+        } else {
+            this.answers = emptyAssessment(this.criteria).answers;
+        }
+    },
+};
+</script>
+
+<style scoped>
+article {
+    padding-inline: 1rem;
+}
+
+.criterium + .criterium {
+    margin-block-start: 2rem;
+}
+
+.criterium header {
+    font-weight: bold;
+    margin-block: 1em;
+}
+
+.criterium-rating > div {
+    font-weight: bold;
+}
+
+.criterium-text {
+    line-height: 1.65;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts
new file mode 100644
index 00000000000..0f246235016
--- /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-Prozess noch nicht aktiv'),
+        };
+    }
+
+    if (endDate < now) {
+        return {
+            status: ProcessStatus.After,
+            shape: 'span-full',
+            role: 'status-green',
+            description: $gettext('Peer-Review-Prozess 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..440fe9dcc0a
--- /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 angegeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.'
+            ),
+        },
+    ];
+}
+
+function defaultCriteriaTable() {
+    return [
+        { text: $gettext('These: Klarheit, Bedeutung') },
+        { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') },
+        { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') },
+        { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') },
+        { text: $gettext('Gesamtwirkung') },
+    ];
+}
diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
index c0e7d92f555..3ba88654ed2 100644
--- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
+++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
@@ -24,6 +24,12 @@
                         {{ $gettext('Aufgabe verteilen') }}
                     </button>
                 </li>
+                <li v-if="taskGroup && !hasPeerReviewProcesses" class="cw-action-widget-add">
+                    <button @click="$emit('add-peer-review-process')">
+                        {{ $gettext('Peer-Review-Verfahren aktivieren') }}
+                    </button>
+                </li>
+
             </ul>
         </template>
     </sidebar-widget>
@@ -39,7 +45,7 @@ export default {
     components: {
         SidebarWidget,
     },
-    props: ['taskGroup'],
+    props: ['hasPeerReviewProcesses', 'taskGroup'],
     computed: {
         isBeforeEndDate() {
             return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']);
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/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js
index b6412d9c4d0..483073015be 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 {
         showTaskGroupsAddSolversDialog: false,
@@ -25,7 +27,7 @@ const getters = {
     taskGroupsByCid(state, getters, rootState, rootGetters) {
         return (cid) => {
             return rootGetters['courseware-task-groups/all'].filter(
-                (taskGroup) => taskGroup.relationships.course.data.id === cid
+                (taskGroup) => taskGroup.relationships.course.data.id === cid,
             );
         };
     },
@@ -34,7 +36,7 @@ const getters = {
             const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id);
 
             return rootGetters['courseware-tasks/all'].filter((task) =>
-                taskGroupIds.includes(task.relationships['task-group'].data.id)
+                taskGroupIds.includes(task.relationships['task-group'].data.id),
             );
         };
     },
@@ -61,14 +63,15 @@ export const actions = {
     loadTasksOfCourse({ dispatch }, { cid }) {
         const options = {
             'filter[cid]': cid,
-            include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+            include:
+                'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes',
         };
         return dispatch('courseware-tasks/loadAll', { options }, { root: true });
     },
 
     loadTaskGroup({ dispatch }, { id }) {
         const options = {
-            include: 'lecturer',
+            include: 'lecturer, peer-review-processes',
         };
         return dispatch('courseware-task-groups/loadById', { id, options }, { root: true });
     },
@@ -84,6 +87,87 @@ export const actions = {
             data: solvers,
         });
     },
+
+    createPeerReviewProcess({ dispatch }, { taskGroup, options }) {
+        const { anonymous, duration, automaticPairing, type, payload } = options;
+
+        const taskGroupEndDate = new Date(taskGroup.attributes['end-date']);
+        taskGroupEndDate.setSeconds(taskGroupEndDate.getSeconds() + 1);
+
+        const now = new Date();
+        const tomorrow = new Date(now);
+        tomorrow.setDate(tomorrow.getDate() + 1);
+        tomorrow.setHours(0, 0, 0, 0);
+
+        const startDate = taskGroupEndDate > now ? taskGroupEndDate : tomorrow;
+
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + duration);
+
+        const data = {
+            attributes: {
+                configuration: { anonymous, duration, automaticPairing, type, payload },
+                'review-start': startDate.toISOString(),
+                'review-end': endDate.toISOString(),
+            },
+            relationships: {
+                'task-group': {
+                    data: {
+                        type: taskGroup.type,
+                        id: taskGroup.id,
+                    },
+                },
+            },
+        };
+
+        return dispatch('courseware-peer-review-processes/create', data, { root: true });
+    },
+
+    replacePairings({ dispatch, rootGetters }, { process, pairings }) {
+        const reviews = rootGetters['courseware-peer-reviews/related']({
+            parent: process,
+            relationship: 'peer-reviews',
+        });
+        const relation = ({ id, type }) => ({ data: { id, type } });
+        const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true });
+        const createReview = (pairing) =>
+            dispatch(
+                'courseware-peer-reviews/create',
+                {
+                    type: 'courseware-peer-reviews',
+                    attributes: {},
+                    relationships: {
+                        process: relation(process),
+                        submitter: relation(pairing.submitter),
+                        reviewer: relation(pairing.reviewer),
+                    },
+                },
+                { root: true },
+            );
+
+        return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview)));
+    },
+
+    updatePeerReviewProcess({ dispatch }, { process, configuration }) {
+        const startDate = new Date(process.attributes['review-start']);
+        const endDate = new Date(startDate);
+        endDate.setDate(endDate.getDate() + configuration.duration);
+
+        if (_.isEmpty(configuration.payload)) {
+            configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload;
+        }
+
+        process.attributes.configuration = configuration;
+        process.attributes['review-start'] = startDate.toISOString();
+        process.attributes['review-end'] = endDate.toISOString();
+
+        return dispatch('courseware-peer-review-processes/update', process, { root: true });
+    },
+
+    storeAssessment({ dispatch }, { review, assessment }) {
+        review.attributes.assessment = assessment;
+        return dispatch('courseware-peer-reviews/update', review, { root: true });
+    },
 };
 
 export const mutations = {
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 9e935313357..8aacdda0ac7 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1278,7 +1278,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/webpack.common.js b/webpack.common.js
index e8f4106ea73..bded2cfe862 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -104,7 +104,8 @@ module.exports = {
         new VueLoaderPlugin(),
         new MiniCssExtractPlugin({
             filename: "stylesheets/[name].css",
-            chunkFilename: "stylesheets/[name].css?h=[chunkhash]"
+            chunkFilename: "stylesheets/[name].css?h=[chunkhash]",
+            ignoreOrder: true,
         }),
         new ESLintPlugin({
             configType: 'flat',
-- 
GitLab