From 1728bc517691b3d0dde5ccdf0c0631ff1b855b6d Mon Sep 17 00:00:00 2001
From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de>
Date: Tue, 23 Jan 2024 07:41:52 +0000
Subject: [PATCH] Courseware Aufgaben erweitern (StEP3286)

Merge request studip/studip!2445
---
 app/controllers/course/courseware.php         |  11 +-
 .../5.5.12_add_dates_to_cw_task_groups.php    |  35 ++
 ...ages.php => 5.5.24_new_external_pages.php} |   0
 lib/classes/JsonApi/RouteMap.php              |   7 +
 .../JsonApi/Routes/Courseware/Authority.php   |  33 +-
 .../Courseware/Rel/SolversOfTaskGroup.php     | 207 ++++++++
 .../Routes/Courseware/TaskGroupsCreate.php    |  25 +-
 .../Routes/Courseware/TaskGroupsDelete.php    |  38 ++
 .../Routes/Courseware/TaskGroupsUpdate.php    |  99 ++++
 .../JsonApi/Routes/Courseware/TasksIndex.php  |   7 +-
 .../JsonApi/Routes/Courseware/TasksUpdate.php |  65 +--
 lib/classes/JsonApi/SchemaMap.php             |  10 +-
 .../JsonApi/Schemas/Courseware/Task.php       |   2 +
 .../JsonApi/Schemas/Courseware/TaskGroup.php  |   3 +
 lib/models/Courseware/Task.php                |  52 +-
 lib/models/Courseware/TaskGroup.php           |  76 ++-
 lib/models/Statusgruppen.php                  |  13 +
 .../javascripts/bootstrap/application.js      |   2 +-
 .../javascripts/bootstrap/consultations.js    |   2 +-
 .../javascripts/bootstrap/copyable_links.js   |   2 +-
 .../javascripts/bootstrap/data_secure.js      |   2 +-
 .../assets/javascripts/bootstrap/forms.js     |   2 +-
 .../javascripts/bootstrap/multi_select.js     |   2 +-
 .../javascripts/bootstrap/mvv_difflog.js      |   2 +-
 .../assets/javascripts/bootstrap/raumzeit.js  |   2 +-
 .../assets/javascripts/bootstrap/resources.js |   2 +-
 .../bootstrap/studip_helper_attributes.js     |   2 +-
 .../assets/javascripts/chunks/tablesorter.js  |   2 +-
 resources/assets/javascripts/chunks/vue.js    |   2 +-
 .../cke/studip-a11y-dialog/a11y-dialog.js     |   2 +-
 .../javascripts/cke/studip-a11y-dialog/ui.js  |   2 +-
 .../cke/studip-quote/StudipBlockQuote.js      |   2 +-
 .../javascripts/cke/wiki-link/formview.js     |   2 +-
 .../assets/javascripts/cke/wiki-link/ui.js    |   2 +-
 resources/assets/javascripts/init.js          |   2 +-
 resources/assets/javascripts/jquery-bundle.js |   4 +-
 resources/assets/javascripts/lib/admission.js |   2 +-
 .../javascripts/lib/big_image_handler.js      |   2 +-
 resources/assets/javascripts/lib/calendar.js  |   2 +-
 resources/assets/javascripts/lib/dialog.js    |   2 +-
 resources/assets/javascripts/lib/files.js     |   2 +-
 resources/assets/javascripts/lib/folders.js   |   2 +-
 resources/assets/javascripts/lib/forum.js     |   2 +-
 .../lib/{gettext.js => gettext.ts}            |  35 +-
 .../assets/javascripts/lib/instschedule.js    |   2 +-
 resources/assets/javascripts/lib/jsupdater.js |   2 +-
 resources/assets/javascripts/lib/lightbox.js  |   2 +-
 resources/assets/javascripts/lib/messages.js  |   2 +-
 .../javascripts/lib/multi_person_search.js    |   2 +-
 .../assets/javascripts/lib/multi_select.js    |   2 +-
 resources/assets/javascripts/lib/oer.js       |   2 +-
 .../assets/javascripts/lib/overlapping.js     |   4 +-
 resources/assets/javascripts/lib/overlay.js   |   2 +-
 resources/assets/javascripts/lib/qr_code.js   |   2 +-
 .../assets/javascripts/lib/questionnaire.js   |   2 +-
 .../assets/javascripts/lib/quick_search.js    |   2 +-
 resources/assets/javascripts/lib/raumzeit.js  |   2 +-
 resources/assets/javascripts/lib/register.js  |   2 +-
 resources/assets/javascripts/lib/resources.js |   2 +-
 resources/assets/javascripts/lib/schedule.js  |   2 +-
 resources/assets/javascripts/lib/tour.js      |   2 +-
 .../assets/javascripts/lib/user_filter.js     |   2 +-
 resources/assets/javascripts/mvv.js           |   2 +-
 .../studip-jquery.multi-select.tweaks.js      |   2 +-
 resources/assets/javascripts/studip-ui.js     |   2 +-
 resources/vue-gettext.d.ts                    |  17 +
 resources/vue/components/StudipDate.vue       |  27 +
 .../CoursewareDashboardStudents.vue           | 481 ------------------
 .../layouts/CoursewareCollapsibleBox.vue      |   3 +-
 .../courseware/tasks/AddFeedbackDialog.vue    |  48 ++
 .../tasks/CoursewareDashboardStudents.vue     | 222 ++++++++
 .../{ => tasks}/CoursewareDashboardTasks.vue  |  10 +-
 .../CoursewareTasksDialogDistribute.vue       |  59 ++-
 .../courseware/tasks/EditFeedbackDialog.vue   |  60 +++
 .../PagesTaskGroupsIndex.vue}                 |  10 +-
 .../courseware/tasks/PagesTaskGroupsShow.vue  | 224 ++++++++
 .../courseware/tasks/RenewalDialog.vue        |  79 +++
 .../components/courseware/tasks/TaskGroup.vue |  84 +++
 .../courseware/tasks/TaskGroupTaskItem.vue    | 118 +++++
 .../tasks/TaskGroupsAddSolversDialog.vue      | 224 ++++++++
 .../tasks/TaskGroupsDeleteDialog.vue          |  33 ++
 .../tasks/TaskGroupsModifyDeadlineDialog.vue  | 117 +++++
 .../courseware/tasks/task-groups-helper.js    |  31 ++
 .../widgets/CoursewareTasksActionWidget.vue   |  49 +-
 .../vue/components/stock-images/colors.js     |   2 +-
 .../vue/components/stock-images/filters.js    |   2 +-
 resources/vue/courseware-index-app.js         |   2 +
 resources/vue/courseware-tasks-app.js         |  50 +-
 .../vue/mixins/courseware/task-helper.js      |   6 +-
 resources/vue/store/AdminCoursesStore.js      |   2 +-
 .../courseware/courseware-tasks.module.js     |  75 ++-
 .../vue/store/courseware/courseware.module.js |   8 +-
 tsconfig.json                                 |   8 +-
 webpack.common.js                             |   1 +
 webpack.dev.js                                |   8 +-
 95 files changed, 2204 insertions(+), 672 deletions(-)
 create mode 100644 db/migrations/5.5.12_add_dates_to_cw_task_groups.php
 rename db/migrations/{5.5.12_new_external_pages.php => 5.5.24_new_external_pages.php} (100%)
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
 rename resources/assets/javascripts/lib/{gettext.js => gettext.ts} (73%)
 create mode 100644 resources/vue-gettext.d.ts
 create mode 100644 resources/vue/components/StudipDate.vue
 delete mode 100644 resources/vue/components/courseware/CoursewareDashboardStudents.vue
 create mode 100644 resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
 rename resources/vue/components/courseware/{ => tasks}/CoursewareDashboardTasks.vue (97%)
 rename resources/vue/components/courseware/{ => tasks}/CoursewareTasksDialogDistribute.vue (92%)
 create mode 100644 resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
 rename resources/vue/components/courseware/{TasksApp.vue => tasks/PagesTaskGroupsIndex.vue} (70%)
 create mode 100644 resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
 create mode 100644 resources/vue/components/courseware/tasks/RenewalDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroup.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
 create mode 100644 resources/vue/components/courseware/tasks/task-groups-helper.js

diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index af7d0e912fd..c1421f486d2 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -79,11 +79,16 @@ class Course_CoursewareController extends CoursewareController
         }
     }
 
-    public function tasks_action(): void
+    public function tasks_action($route = null): void
     {
-        global $perm, $user;
-        $this->is_teacher = $perm->have_studip_perm('tutor', Context::getId(), $user->id);
+        $this->is_teacher = $GLOBALS['perm']->have_studip_perm(
+            'tutor',
+            Context::getId(),
+            $GLOBALS['user']->id
+        );
+
         Navigation::activateItem('course/courseware/tasks');
+        PageLayout::setTitle(_('Courseware: Aufgaben'));
         $this->setTasksSidebar();
     }
 
diff --git a/db/migrations/5.5.12_add_dates_to_cw_task_groups.php b/db/migrations/5.5.12_add_dates_to_cw_task_groups.php
new file mode 100644
index 00000000000..aba5ea99f83
--- /dev/null
+++ b/db/migrations/5.5.12_add_dates_to_cw_task_groups.php
@@ -0,0 +1,35 @@
+<?php
+class AddDatesToCwTaskGroups extends Migration
+{
+    public function description()
+    {
+        return 'Add start_date and end_date to table cw_task_groups.';
+    }
+
+    public function up()
+    {
+        $dbm = \DBManager::get();
+        $dbm->exec(
+            "ALTER TABLE `cw_task_groups`
+             ADD `start_date` INT NOT NULL AFTER `title`,
+             ADD `end_date` INT NOT NULL AFTER `start_date`"
+        );
+        $dbm->exec('UPDATE `cw_task_groups` SET `start_date`=`mkdate`');
+        $dbm->exec(
+            'UPDATE `cw_task_groups` AS tg SET tg.`end_date` = ( SELECT MAX(t.`submission_date`) FROM `cw_tasks` t WHERE t.`task_group_id` = tg.`id` )'
+        );
+        $dbm->exec('ALTER TABLE `cw_tasks` DROP `submission_date`');
+    }
+
+    public function down()
+    {
+        $dbm = \DBManager::get();
+        $dbm->exec("ALTER TABLE `cw_tasks` ADD `submission_date` int(11) NOT NULL AFTER `solver_type`");
+        $dbm->exec('UPDATE `cw_tasks` AS t INNER JOIN cw_task_groups tg ON t.`task_group_id` = tg.`id` SET t.`submission_date` = tg.`end_date`');
+        $dbm->exec(
+            'ALTER TABLE `cw_task_groups`
+             DROP `start_date`,
+             DROP `end_date`'
+        );
+    }
+}
diff --git a/db/migrations/5.5.12_new_external_pages.php b/db/migrations/5.5.24_new_external_pages.php
similarity index 100%
rename from db/migrations/5.5.12_new_external_pages.php
rename to db/migrations/5.5.24_new_external_pages.php
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index d4d5bbb2121..4f441651ba7 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -501,6 +501,13 @@ class RouteMap
 
         $group->get('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsShow::class);
         $group->post('/courseware-task-groups', Routes\Courseware\TaskGroupsCreate::class);
+        $group->patch('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsUpdate::class);
+        $group->delete('/courseware-task-groups/{id}', Routes\Courseware\TaskGroupsDelete::class);
+        $this->addRelationship(
+            $group,
+            '/courseware-task-groups/{id}/relationships/solvers',
+            Routes\Courseware\Rel\SolversOfTaskGroup::class
+        );
 
         $group->get('/courseware-task-feedback/{id}', Routes\Courseware\TaskFeedbackShow::class);
         $group->post('/courseware-task-feedback', Routes\Courseware\TaskFeedbackCreate::class);
diff --git a/lib/classes/JsonApi/Routes/Courseware/Authority.php b/lib/classes/JsonApi/Routes/Courseware/Authority.php
index 88eb3df36b8..2acf83e3a6b 100644
--- a/lib/classes/JsonApi/Routes/Courseware/Authority.php
+++ b/lib/classes/JsonApi/Routes/Courseware/Authority.php
@@ -23,7 +23,13 @@ use User;
 use Course;
 
 /**
+ * @SuppressWarnings(PHPMD.CamelCaseParameterName)
+ * @SuppressWarnings(PHPMD.CamelCaseVariableName)
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
+ * @SuppressWarnings(PHPMD.ExcessivePublicCount)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ * @SuppressWarnings(PHPMD.Superglobals)
  * @SuppressWarnings(PHPMD.TooManyMethods)
  * @SuppressWarnings(PHPMD.TooManyPublicMethods)
  */
@@ -306,6 +312,16 @@ class Authority
         return $resource['lecturer_id'] === $user->id;
     }
 
+    public static function canUpdateTaskGroup(User $user, TaskGroup $resource): bool
+    {
+        return self::canCreateTasks($user, $resource->target);
+    }
+
+    public static function canDeleteTaskGroup(User $user, TaskGroup $resource): bool
+    {
+        return self::canUpdateTaskGroup($user, $resource);
+    }
+
     public static function canShowTask(User $user, Task $resource): bool
     {
         return self::canUpdateTask($user, $resource);
@@ -332,6 +348,11 @@ class Authority
         return self::canCreateTasks($user, $resource->structural_element) && !$resource->userIsASolver($user);
     }
 
+    public static function canRenewTask(User $user, Task $resource): bool
+    {
+        return self::canDeleteTask($user, $resource);
+    }
+
     public static function canCreateTaskFeedback(User $user, Task $resource): bool
     {
         return self::canCreateTasks($user, $resource->structural_element);
@@ -352,7 +373,6 @@ class Authority
         return self::canCreateTaskFeedback($user, $resource);
     }
 
-
     public static function canIndexStructuralElementComments(User $user, StructuralElement $resource)
     {
         return self::canShowStructuralElement($user, $resource);
@@ -407,7 +427,8 @@ class Authority
 
     public static function canShowStructuralElementFeedback(User $user, StructuralElementFeedback $resource)
     {
-        return $resource->user_id === $user->id || self::canUpdateStructuralElement($user, $resource->structural_element);
+        return $resource->user_id === $user->id ||
+            self::canUpdateStructuralElement($user, $resource->structural_element);
     }
 
     public static function canDeleteStructuralElementFeedback(User $user, StructuralElementFeedback $resource)
@@ -415,7 +436,6 @@ class Authority
         return self::canUpdateStructuralElementFeedback($user, $resource);
     }
 
-
     public static function canShowTemplate(User $user, Template $resource)
     {
         // templates are for everybody, aren't they?
@@ -430,7 +450,7 @@ class Authority
 
     public static function canCreateTemplate(User $user)
     {
-        return $GLOBALS['perm']->have_perm('admin');
+        return $GLOBALS['perm']->have_perm('admin', $user->id);
     }
 
     public static function canUpdateTemplate(User $user, Template $resource)
@@ -490,7 +510,7 @@ class Authority
         if ($user->id === $range->id) {
             return true;
         }
-        return $GLOBALS['perm']->have_studip_perm('tutor', $range->id ,$user->id);
+        return $GLOBALS['perm']->have_studip_perm('tutor', $range->id, $user->id);
     }
 
     public static function canSortUnit(User $user, \Range $range): bool
@@ -518,7 +538,6 @@ class Authority
         return $request_user->id === $user->id;
     }
 
-
     public static function canShowClipboard(User $user, Clipboard $resource): bool
     {
         return $resource->user_id === $user->id;
@@ -541,7 +560,7 @@ class Authority
         } else {
             $structural_element = $resource->getStructuralElement();
         }
-        
+
         return $structural_element->canEdit($user);
     }
 
diff --git a/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php b/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php
new file mode 100644
index 00000000000..2ab5ffa6da1
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/Rel/SolversOfTaskGroup.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace JsonApi\Routes\Courseware\Rel;
+
+use Courseware\StructuralElement;
+use Courseware\Task;
+use Courseware\TaskGroup;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Errors\UnprocessableEntityException;
+use JsonApi\Routes\Courseware\Authority;
+use JsonApi\Routes\RelationshipsController;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use JsonApi\Schemas\StatusGroup as StatusGroupSchema;
+use JsonApi\Schemas\User as UserSchema;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Statusgruppen;
+use User;
+
+/**
+ * @SuppressWarnings(PHPMD.LongVariable)
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class SolversOfTaskGroup extends RelationshipsController
+{
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function fetchRelationship(Request $request, $related)
+    {
+        $solvers = $related->getSolvers();
+        $total = count($solvers);
+
+        return $this->getPaginatedIdentifiersResponse(array_slice($solvers, ...$this->getOffsetAndLimit()), $total);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function addToRelationship(Request $request, $related)
+    {
+        $this->createTaskFor(
+            $related,
+            array_filter($this->validateSolvers($related, $this->validate($request)), function ($solver) use (
+                $related
+            ) {
+                return !$related->findTaskBySolver($solver);
+            })
+        );
+
+        return $this->getCodeResponse(204);
+    }
+
+    protected function findRelated(array $args)
+    {
+        $related = TaskGroup::find($args['id']);
+        if (!$related) {
+            throw new RecordNotFoundException();
+        }
+
+        return $related;
+    }
+
+    protected function authorize(Request $request, $resource)
+    {
+        switch ($request->getMethod()) {
+            case 'GET':
+                return Authority::canShowTaskGroup($this->getUser($request), $resource);
+            case 'POST':
+                return Authority::canUpdateTaskGroup($this->getUser($request), $resource);
+
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function getRelationshipSelfLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipSelfLink($resource, TaskGroupSchema::REL_SOLVERS);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function getRelationshipRelatedLink($resource, $schema, $userData)
+    {
+        return $schema->getRelationshipRelatedLink($resource, TaskGroupSchema::REL_SOLVERS);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        $data = self::arrayGet($json, 'data');
+
+        if (!is_array($data)) {
+            return 'Document´s `data` must be an array.';
+        }
+
+        foreach ($data as $item) {
+            if (!in_array(self::arrayGet($item, 'type'), [UserSchema::TYPE, StatusGroupSchema::TYPE])) {
+                return 'Wrong `type` in document´s `data`.';
+            }
+
+            if (!self::arrayGet($item, 'id')) {
+                return 'Missing `id` of document´s `data`.';
+            }
+        }
+    }
+
+    private function validateSolvers(TaskGroup $taskGroup, iterable $json): iterable
+    {
+        if (!$taskGroup->course) {
+            return [];
+        }
+        $solvers = [];
+        foreach ($json['data'] as $item) {
+            $solver = $this->findSolver($item);
+            if (!$solver) {
+                throw new RecordNotFoundException();
+            }
+            if (!$this->validateSolver($taskGroup, $solver)) {
+                throw new UnprocessableEntityException();
+            }
+            $solvers[] = $solver;
+        }
+        return $solvers;
+    }
+
+    /**
+     * @return Statusgruppen|User|null
+     */
+    private function findSolver($json)
+    {
+        switch ($json['type']) {
+            case 'status-groups':
+                return Statusgruppen::find($json['id']);
+            case 'users':
+                return User::find($json['id']);
+        }
+        return null;
+    }
+
+    /**
+     * @param Statusgruppen|User $solver
+     *
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    private function validateSolver(TaskGroup $taskGroup, $solver): bool
+    {
+        if ($solver instanceof User) {
+            return $GLOBALS['perm']->have_studip_perm('autor', $taskGroup->course->id, $solver->id);
+        }
+        if ($solver instanceof Statusgruppen) {
+            return $taskGroup->course->id === $solver->range_id;
+        }
+
+        return false;
+    }
+
+    /**
+     * @param array<User|Statusgruppen> $solvers
+     */
+    private function createTaskFor(TaskGroup $taskGroup, $solvers): void
+    {
+        $template = $this->getTaskTemplate($taskGroup);
+        if (!$template) {
+            throw new RuntimeException();
+        }
+
+        foreach ($solvers as $solver) {
+            $task = Task::build([
+                'task_group_id' => $taskGroup->id,
+                'solver_id' => $solver->id,
+                'solver_type' => $this->getSolverType($solver),
+            ]);
+
+            $taskElement = $template->copy($taskGroup->lecturer, $taskGroup->target, 'task');
+            $taskElement->title = $taskGroup->title;
+            $taskElement->store();
+
+            $task['structural_element_id'] = $taskElement->id;
+            $task->store();
+        }
+    }
+
+    private function getTaskTemplate(TaskGroup $taskGroup): StructuralElement
+    {
+        return StructuralElement::find($taskGroup->task_template_id);
+    }
+
+    /**
+     * @param User|Statusgruppen $solver
+     */
+    private function getSolverType($solver): string
+    {
+        $solverTypes = [\User::class => 'autor', \Statusgruppen::class => 'group'];
+
+        return $solverTypes[get_class($solver)];
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
index 28c4e9ce65c..f7357a43fe4 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsCreate.php
@@ -65,14 +65,20 @@ class TaskGroupsCreate extends JsonApiController
         if (!self::arrayHas($json, 'data.attributes.title')) {
             return 'Missing `title` attribute.';
         }
-        if (!self::arrayHas($json, 'data.attributes.submission-date')) {
-            return 'Missing `submission-date` attribute.';
+        if (!self::arrayHas($json, 'data.attributes.start-date')) {
+            return 'Missing `start-date` attribute.';
         }
-        $submissionDate = self::arrayGet($json, 'data.attributes.submission-date');
-        if (!self::isValidTimestamp($submissionDate)) {
-            return '`submission-date` is not an ISO 8601 timestamp.';
+        $startDate = self::arrayGet($json, 'data.attributes.start-date');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`start-date` is not an ISO 8601 timestamp.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.end-date')) {
+            return 'Missing `end-date` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.end-date');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`end-date` is not an ISO 8601 timestamp.';
         }
-
         if (!self::arrayHas($json, 'data.relationships.target')) {
             return 'Missing `target` relationship.';
         }
@@ -165,8 +171,8 @@ class TaskGroupsCreate extends JsonApiController
         $target = $this->getTargetFromJson($json);
 
         $solverMayAddBlocks = self::arrayGet($json, 'data.attributes.solver-may-add-blocks', '');
-        $submissionDate = self::arrayGet($json, 'data.attributes.submission-date', '');
-        $submissionDate = self::fromISO8601($submissionDate);
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date', ''));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date', ''));
         $title = self::arrayGet($json, 'data.attributes.title', '');
 
         /** @var TaskGroup $taskGroup */
@@ -177,6 +183,8 @@ class TaskGroupsCreate extends JsonApiController
             'task_template_id' => $taskTemplate->getId(),
             'solver_may_add_blocks' => $solverMayAddBlocks,
             'title' => $title,
+            'start_date' => $startDate->getTimestamp(),
+            'end_date' => $endDate->getTimestamp(),
         ]);
 
         foreach ($solvers as $solver) {
@@ -184,7 +192,6 @@ class TaskGroupsCreate extends JsonApiController
                 'task_group_id' => $taskGroup->getId(),
                 'solver_id' => $solver->getId(),
                 'solver_type' => $this->getSolverType($solver),
-                'submission_date' => $submissionDate->getTimestamp(),
             ]);
 
             // copy task template
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
new file mode 100644
index 00000000000..2faf7783bc2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Delete one TaskGroup.
+ */
+class TaskGroupsDelete extends JsonApiController
+{
+    /**
+     * @param array $args
+     * @return Response
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?TaskGroup $resource */
+        $resource = TaskGroup::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        if (!Authority::canDeleteTaskGroup($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
new file mode 100644
index 00000000000..8662b71ea80
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/TaskGroupsUpdate.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use Courseware\TaskGroup;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\TimestampTrait;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Courseware\TaskGroup as TaskGroupSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use User;
+
+/**
+ * Updates one TaskGroup.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+class TaskGroupsUpdate extends JsonApiController
+{
+    use TimestampTrait;
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     * @param array $args
+     * @return Response
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        /** @var ?\Courseware\TaskGroup $resource */
+        $resource = TaskGroup::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateTaskGroup($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $process = $this->update($resource, $json);
+
+        return $this->getContentResponse($process);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     *
+     * @param array $json
+     * @param mixed $data
+     *
+     * @return string|void
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (TaskGroupSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Invalid `type` of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.start-date')) {
+            return 'Missing `start-date` attribute.';
+        }
+        $startDate = self::arrayGet($json, 'data.attributes.start-date');
+        if (!self::isValidTimestamp($startDate)) {
+            return '`start-date` is not an ISO 8601 timestamp.';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.end-date')) {
+            return 'Missing `end-date` attribute.';
+        }
+        $endDate = self::arrayGet($json, 'data.attributes.end-date');
+        if (!self::isValidTimestamp($endDate)) {
+            return '`end-date` is not an ISO 8601 timestamp.';
+        }
+
+        if (self::fromISO8601($startDate) > self::fromISO8601($endDate)) {
+            return '`start-date` is later than `end-date`';
+        }
+    }
+
+    private function update(TaskGroup $taskGroup, array $json): TaskGroup
+    {
+        $startDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.start-date'));
+        $endDate = self::fromISO8601(self::arrayGet($json, 'data.attributes.end-date'));
+
+        $taskGroup->start_date = $startDate->getTimestamp();
+        $taskGroup->end_date = $endDate->getTimestamp();
+
+        $taskGroup->store();
+
+        return $taskGroup;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
index f0b2ce9a53a..26a021c9682 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksIndex.php
@@ -77,9 +77,10 @@ class TasksIndex extends JsonApiController
         }
     }
 
-    private function findTasksByCourse(\Course $course): \SimpleCollection
+    private function findTasksByCourse(\Course $course, bool $showNotYetActive = true): \SimpleCollection
     {
-        $taskGroups = TaskGroup::findBySQL('seminar_id = ?', [$course->getId()]);
+        $whereClause = $showNotYetActive ? 'seminar_id = ?' : 'start_date <= UNIX_TIMESTAMP() AND seminar_id = ?';
+        $taskGroups = TaskGroup::findBySQL($whereClause, [$course->getId()]);
 
         $tasks = [];
         foreach ($taskGroups as $taskGroup) {
@@ -98,7 +99,7 @@ class TasksIndex extends JsonApiController
             })
             ->pluck('id');
 
-        return $this->findTasksByCourse($course)->filter(function ($task) use ($user, $groupIds) {
+        return $this->findTasksByCourse($course, false)->filter(function ($task) use ($user, $groupIds) {
             return ('autor' === $task['solver_type'] && $task['solver_id'] === $user->getId()) ||
                 ('group' === $task['solver_type'] && in_array($task['solver_id'], $groupIds));
         });
diff --git a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
index 3728dba9a6b..33b51ad1ae8 100644
--- a/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/TasksUpdate.php
@@ -13,6 +13,8 @@ use Psr\Http\Message\ServerRequestInterface as Request;
 
 /**
  * Update one Task.
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class TasksUpdate extends JsonApiController
 {
@@ -32,7 +34,8 @@ class TasksUpdate extends JsonApiController
             throw new RecordNotFoundException();
         }
         $json = $this->validate($request, $resource);
-        if (!Authority::canUpdateTask($user = $this->getUser($request), $resource)) {
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateTask($user, $resource)) {
             throw new AuthorizationFailedException();
         }
         $resource = $this->updateTask($user, $resource, $json);
@@ -66,53 +69,35 @@ class TasksUpdate extends JsonApiController
 
     private function updateTask(\User $user, Task $resource, array $json): Task
     {
-        if (Authority::canDeleteTask($user, $resource)) {
-            if (self::arrayHas($json, 'data.attributes.renewal')) {
-                $newRenewalState = self::arrayGet($json, 'data.attributes.renewal');
-                if ('declined' === $newRenewalState) {
-                    $resource->renewal = $newRenewalState;
-                }
-                if ('granted' === $newRenewalState && self::arrayHas($json, 'data.attributes.renewal-date')) {
-                    $renewalDate = self::arrayGet($json, 'data.attributes.renewal-date', '');
-                    $renewalDate = self::fromISO8601($renewalDate);
+        if (Authority::canRenewTask($user, $resource)) {
+            return $this->renewTask($resource, $json);
+        }
 
-                    $resource->renewal = $newRenewalState;
-                    $resource->renewal_date = $renewalDate->getTimestamp();
-                }
-            }
-        } else {
-            if (self::arrayHas($json, 'data.attributes.submitted')) {
-                $newSubmittedState = self::arrayGet($json, 'data.attributes.submitted');
-                if ($this->canSubmit($resource, $newSubmittedState)) {
-                    $resource->submitted = $newSubmittedState;
-                    if ('pending' === $resource->renewal) {
-                        $resource->renewal = '';
-                    }
-                }
-            }
-            if (self::arrayHas($json, 'data.attributes.renewal')) {
-                $newRenewalState = self::arrayGet($json, 'data.attributes.renewal');
-                if ('pending' === $newRenewalState) {
-                    $resource->renewal = $newRenewalState;
-                }
-            }
+        if (self::arrayGet($json, 'data.attributes.submitted') === true && $resource->canSubmit()) {
+            $resource->submitTask();
         }
 
-        $resource->store();
+        if (self::arrayGet($json, 'data.attributes.renewal') === 'pending') {
+            $resource->requestRenewal();
+        }
 
         return $resource;
     }
 
-    private function canSubmit(Task $resource, string $newSubmittedState): bool
+    private function renewTask(Task $resource, array $json): Task
     {
-        $now = time();
-        if (1 === (int) $resource->submitted || !$newSubmittedState) {
-            return false;
-        }
-        if ('granted' === $resource->renewal) {
-            return $now <= $resource->renewal_date;
-        } else {
-            return $now <= $resource->submission_date;
+        switch (self::arrayGet($json, 'data.attributes.renewal')) {
+            case 'declined':
+                $resource->declineRenewalRequest();
+                break;
+
+            case 'granted':
+                $resource->grantRenewalRequest(
+                    self::fromISO8601(self::arrayGet($json, 'data.attributes.renewal-date'))
+                );
+                break;
         }
+
+        return $resource;
     }
 }
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index dd74bc9bc2a..71aadf7124a 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -59,17 +59,17 @@ class SchemaMap
             \Courseware\Clipboard::class => Schemas\Courseware\Clipboard::class,
             \Courseware\Container::class => Schemas\Courseware\Container::class,
             \Courseware\Instance::class => Schemas\Courseware\Instance::class,
+            \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
             \Courseware\StructuralElement::class => Schemas\Courseware\StructuralElement::class,
             \Courseware\StructuralElementComment::class => Schemas\Courseware\StructuralElementComment::class,
             \Courseware\StructuralElementFeedback::class => Schemas\Courseware\StructuralElementFeedback::class,
-            \Courseware\Unit::class => Schemas\Courseware\Unit::class,
-            \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class,
-            \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class,
             \Courseware\Task::class => Schemas\Courseware\Task::class,
-            \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class,
             \Courseware\TaskFeedback::class => Schemas\Courseware\TaskFeedback::class,
+            \Courseware\TaskGroup::class => Schemas\Courseware\TaskGroup::class,
             \Courseware\Template::class => Schemas\Courseware\Template::class,
-            \Courseware\PublicLink::class => Schemas\Courseware\PublicLink::class,
+            \Courseware\Unit::class => Schemas\Courseware\Unit::class,
+            \Courseware\UserDataField::class => Schemas\Courseware\UserDataField::class,
+            \Courseware\UserProgress::class => Schemas\Courseware\UserProgress::class,
         ];
     }
 }
diff --git a/lib/classes/JsonApi/Schemas/Courseware/Task.php b/lib/classes/JsonApi/Schemas/Courseware/Task.php
index a0605e609d8..81c7a0d18a5 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/Task.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/Task.php
@@ -2,6 +2,8 @@
 
 namespace JsonApi\Schemas\Courseware;
 
+use Courseware\Task as TaskModel;
+use JsonApi\Routes\Courseware\Authority as CoursewareAuthority;
 use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Link;
diff --git a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
index 12dbc6c5855..c950671ea47 100644
--- a/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
+++ b/lib/classes/JsonApi/Schemas/Courseware/TaskGroup.php
@@ -3,6 +3,7 @@
 namespace JsonApi\Schemas\Courseware;
 
 use Courseware\StructuralElement;
+use Courseware\TaskGroup as TaskGroupModel;
 use JsonApi\Schemas\SchemaProvider;
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
 use Neomerx\JsonApi\Schema\Identifier;
@@ -35,6 +36,8 @@ class TaskGroup extends SchemaProvider
         return [
             'solver-may-add-blocks' => (bool) $resource['solver_may_add_blocks'],
             'title' => (string) $resource->title,
+            'start-date' => date('c', $resource['start_date']),
+            'end-date' => date('c', $resource['end_date']),
             'mkdate' => date('c', $resource['mkdate']),
             'chdate' => date('c', $resource['chdate']),
         ];
diff --git a/lib/models/Courseware/Task.php b/lib/models/Courseware/Task.php
index 3a68d3e4ffe..d409676ca3c 100644
--- a/lib/models/Courseware/Task.php
+++ b/lib/models/Courseware/Task.php
@@ -31,7 +31,9 @@ use User;
  * @property \Statusgruppen $group belongs_to \Statusgruppen
  * @property \Course $course belongs_to \Course
  * @property TaskFeedback|null $task_feedback belongs_to TaskFeedback
- * @property mixed $solver additional field
+ * @property-read \User|\Statusgruppen|null $solver additional field
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class Task extends \SimpleORMap
 {
@@ -80,6 +82,10 @@ class Task extends \SimpleORMap
             'get' => 'getSolver',
             'set' => false,
         ];
+        $config['additional_fields']['submission_date'] = [
+            'get' => 'getSubmissionDate',
+            'set' => false,
+        ];
 
         parent::configure($config);
     }
@@ -171,6 +177,11 @@ class Task extends \SimpleORMap
         return null;
     }
 
+    public function getSubmissionDate(): int
+    {
+        return $this->task_group['end_date'];
+    }
+
     public function getTaskProgress(): float
     {
         $children = $this->structural_element->findDescendants();
@@ -185,6 +196,45 @@ class Task extends \SimpleORMap
         return $progress * 100;
     }
 
+    public function canSubmit(): bool
+    {
+        return !$this->submitted
+            && time() <= ('granted' === $this->renewal ? $this->renewal_date : $this->submission_date);
+    }
+
+    public function submitTask(): void
+    {
+        $this->submitted = 1;
+        if ('pending' === $this->renewal) {
+            $this->renewal = '';
+        }
+        $this->store();
+    }
+
+    public function isRenewed(): bool
+    {
+        return $this->renewal === 'granted';
+    }
+
+    public function requestRenewal(): void
+    {
+        $this->renewal = 'pending';
+        $this->store();
+    }
+
+    public function declineRenewalRequest(): void
+    {
+        $this->renewal = 'declined';
+        $this->store();
+    }
+
+    public function grantRenewalRequest(\DateTime $renewalDate): void
+    {
+        $this->renewal = 'granted';
+        $this->renewal_date = $renewalDate->getTimestamp();
+        $this->store();
+    }
+
     private function getStructuralElementProgress(StructuralElement $structural_element): float
     {
         $containers = Container::findBySQL('structural_element_id = ?', [intval($structural_element->id)]);
diff --git a/lib/models/Courseware/TaskGroup.php b/lib/models/Courseware/TaskGroup.php
index 092edf644a4..6902cb36a67 100644
--- a/lib/models/Courseware/TaskGroup.php
+++ b/lib/models/Courseware/TaskGroup.php
@@ -2,6 +2,8 @@
 
 namespace Courseware;
 
+use DBManager;
+use Statusgruppen;
 use User;
 
 /**
@@ -19,11 +21,17 @@ use User;
  * @property int $task_template_id database column
  * @property int $solver_may_add_blocks database column
  * @property string $title database column
+ * @property int $start_date database column
+ * @property int $end_date database column
  * @property int $mkdate database column
  * @property int $chdate database column
  * @property \SimpleORMapCollection|Task[] $tasks has_many Task
  * @property \User $lecturer belongs_to \User
  * @property \Course $course belongs_to \Course
+ * @property \Courseware\StructuralElement $target belongs_to Courseware\StructuralElement
+ * @property \SimpleORMapCollection $tasks has_many Courseware\Task
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
  */
 class TaskGroup extends \SimpleORMap implements \PrivacyObject
 {
@@ -41,6 +49,11 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
             'foreign_key' => 'seminar_id',
         ];
 
+        $config['belongs_to']['target'] = [
+            'class_name' => StructuralElement::class,
+            'foreign_key' => 'target_id',
+        ];
+
         $config['has_many']['tasks'] = [
             'class_name' => Task::class,
             'assoc_foreign_key' => 'task_group_id',
@@ -52,6 +65,22 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
         parent::configure($config);
     }
 
+    /**
+     * Export available data of a given user into a storage object
+     * (an instance of the StoredUserData class) for that user.
+     *
+     * @param StoredUserData $storage object to store data into
+     */
+    public static function exportUserData(\StoredUserData $storage)
+    {
+        $task_groups = DBManager::get()->fetchAll('SELECT * FROM cw_task_groups WHERE lecturer_id = ?', [
+            $storage->user_id,
+        ]);
+        if ($task_groups) {
+            $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups);
+        }
+    }
+
     public function getSolvers(): iterable
     {
         $solvers = $this->tasks->pluck('solver');
@@ -60,20 +89,45 @@ class TaskGroup extends \SimpleORMap implements \PrivacyObject
     }
 
     /**
-     * Export available data of a given user into a storage object
-     * (an instance of the StoredUserData class) for that user.
+     * Returns all submitters of this TaskGroup.
      *
-     * @param StoredUserData $storage object to store data into
+     * @returns iterable all the submitters of this TaskGroup.
      */
-    public static function exportUserData(\StoredUserData $storage)
+    public function getSubmitters(): iterable
     {
-        $task_groups = \DBManager::get()->fetchAll(
-            'SELECT * FROM cw_task_groups WHERE lecturer_id = ?',
-            [$storage->user_id]
+        return DBManager::get()->fetchAll(
+            'SELECT solver_id, solver_type FROM cw_tasks WHERE task_group_id = ? AND submitted = 1',
+            [$this->getId()],
+            function ($row) {
+                switch ($row['solver_type']) {
+                    case 'autor':
+                        return \User::find($row['solver_id']);
+                    case 'group':
+                        return \Statusgruppen::find($row['solver_id']);
+                }
+            }
         );
-        if ($task_groups) {
-            $storage->addTabularData(_('Courseware Aufgaben'), 'cw_task_groups', $task_groups);
-        }
-        
     }
+
+    /**
+     * Returns the task of this TaskGroup given to $solver.
+     *
+     * @param User|Statusgruppen $solver
+     *
+     * @return Task|null
+     */
+    public function findTaskBySolver($solver)
+    {
+        $row = DBManager::get()->fetchOne(
+            'SELECT id FROM cw_tasks WHERE task_group_id = ? AND solver_id = ? AND solver_type = ?',
+            [
+                $this->getId(),
+                $solver->getId(),
+                $solver instanceof User ? 'autor' : 'group',
+            ]
+        );
+
+        return empty($row) ? null : Task::find($row['id']);
+    }
+
 }
diff --git a/lib/models/Statusgruppen.php b/lib/models/Statusgruppen.php
index e58611084f0..e0d2575595e 100644
--- a/lib/models/Statusgruppen.php
+++ b/lib/models/Statusgruppen.php
@@ -740,4 +740,17 @@ class Statusgruppen extends SimpleORMap implements PrivacyObject
             }
         }
     }
+
+    /**
+     * Checks if a user is a member of a group.
+     *
+     * @param string $user_id The user id
+     * @return boolean <b>true</b> if user is a member of this group
+     *
+     * @SuppressWarnings(PHPMD.StaticAccess)
+     */
+    public static function isMemberOf(string $gruppenId, string $userId): bool
+    {
+        return StatusgruppeUser::countBySql('statusgruppe_id = ? AND user_id = ?', [$gruppenId, $userId]) !== 0;
+    }
 }
diff --git a/resources/assets/javascripts/bootstrap/application.js b/resources/assets/javascripts/bootstrap/application.js
index c8312618687..a9f53dfb2c5 100644
--- a/resources/assets/javascripts/bootstrap/application.js
+++ b/resources/assets/javascripts/bootstrap/application.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 import eventBus from "../lib/event-bus.ts";
 
 /* ------------------------------------------------------------------------
diff --git a/resources/assets/javascripts/bootstrap/consultations.js b/resources/assets/javascripts/bootstrap/consultations.js
index dec8f4aee86..ef79d9ca1e6 100644
--- a/resources/assets/javascripts/bootstrap/consultations.js
+++ b/resources/assets/javascripts/bootstrap/consultations.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 $(document).on('click', '.consultation-delete-check:not(.ignore)', event => {
     const form       = $(event.target).closest('form');
diff --git a/resources/assets/javascripts/bootstrap/copyable_links.js b/resources/assets/javascripts/bootstrap/copyable_links.js
index d3675ed7a82..521eae4c5ff 100644
--- a/resources/assets/javascripts/bootstrap/copyable_links.js
+++ b/resources/assets/javascripts/bootstrap/copyable_links.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 $(document).on('click', 'a.copyable-link', function (event) {
     event.preventDefault();
diff --git a/resources/assets/javascripts/bootstrap/data_secure.js b/resources/assets/javascripts/bootstrap/data_secure.js
index a1a5ac7ae95..1b3b7a1072e 100644
--- a/resources/assets/javascripts/bootstrap/data_secure.js
+++ b/resources/assets/javascripts/bootstrap/data_secure.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 /**
  * Secure forms or form elements by displaying a warning on page unload if
diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js
index bbc3d8a01ed..1f4937d19c1 100644
--- a/resources/assets/javascripts/bootstrap/forms.js
+++ b/resources/assets/javascripts/bootstrap/forms.js
@@ -1,4 +1,4 @@
-import { $gettext, $gettextInterpolate } from '../lib/gettext.js';
+import { $gettext, $gettextInterpolate } from '../lib/gettext';
 
 // Allow fieldsets to collapse
 $(document).on(
diff --git a/resources/assets/javascripts/bootstrap/multi_select.js b/resources/assets/javascripts/bootstrap/multi_select.js
index 9e817b8f6a4..5996bd7001e 100644
--- a/resources/assets/javascripts/bootstrap/multi_select.js
+++ b/resources/assets/javascripts/bootstrap/multi_select.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 import eventBus from "../lib/event-bus.ts";
 
 eventBus.on('studip:set-locale', () => {
diff --git a/resources/assets/javascripts/bootstrap/mvv_difflog.js b/resources/assets/javascripts/bootstrap/mvv_difflog.js
index f21c3680e6e..8ade9181925 100644
--- a/resources/assets/javascripts/bootstrap/mvv_difflog.js
+++ b/resources/assets/javascripts/bootstrap/mvv_difflog.js
@@ -1,4 +1,4 @@
-import { $gettext, $gettextInterpolate } from  '../lib/gettext.js';
+import { $gettext, $gettextInterpolate } from  '../lib/gettext';
 
 STUDIP.domReady(() => {
     $('del.diffdel').each(function() {
diff --git a/resources/assets/javascripts/bootstrap/raumzeit.js b/resources/assets/javascripts/bootstrap/raumzeit.js
index 2140497d57c..241105b84e4 100644
--- a/resources/assets/javascripts/bootstrap/raumzeit.js
+++ b/resources/assets/javascripts/bootstrap/raumzeit.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 STUDIP.Dialog.handlers.header['X-Raumzeit-Update-Times'] = function(json) {
     var info = $.parseJSON(json);
diff --git a/resources/assets/javascripts/bootstrap/resources.js b/resources/assets/javascripts/bootstrap/resources.js
index 388f47576e5..25582d43bdf 100644
--- a/resources/assets/javascripts/bootstrap/resources.js
+++ b/resources/assets/javascripts/bootstrap/resources.js
@@ -1,4 +1,4 @@
-import {$gettext} from '../lib/gettext.js';
+import {$gettext} from '../lib/gettext';
 
 STUDIP.ready(function () {
     //Event definitions:
diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
index 8f96dcbe480..c106de3dcd7 100644
--- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
+++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 /**
  * This file provides a set of global handlers.
diff --git a/resources/assets/javascripts/chunks/tablesorter.js b/resources/assets/javascripts/chunks/tablesorter.js
index 9cc8b0df3e9..047c7ce0899 100644
--- a/resources/assets/javascripts/chunks/tablesorter.js
+++ b/resources/assets/javascripts/chunks/tablesorter.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js'
+import { $gettext } from '../lib/gettext'
 
 import "tablesorter/dist/js/jquery.tablesorter"
 import "tablesorter/dist/js/extras/jquery.tablesorter.pager.min.js"
diff --git a/resources/assets/javascripts/chunks/vue.js b/resources/assets/javascripts/chunks/vue.js
index cf95ed3c5f7..b98cc2707b5 100644
--- a/resources/assets/javascripts/chunks/vue.js
+++ b/resources/assets/javascripts/chunks/vue.js
@@ -3,7 +3,7 @@ import Vuex from 'vuex';
 import Router from "vue-router";
 import eventBus from '../lib/event-bus.ts';
 import GetTextPlugin from 'vue-gettext';
-import { getLocale, getVueConfig } from '../lib/gettext.js';
+import { getLocale, getVueConfig } from '../lib/gettext';
 import PortalVue from 'portal-vue';
 import BaseComponents from '../../../vue/base-components.js';
 import BaseDirectives from "../../../vue/base-directives.js";
diff --git a/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js b/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js
index 814e931e671..41195b02f20 100644
--- a/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js
+++ b/resources/assets/javascripts/cke/studip-a11y-dialog/a11y-dialog.js
@@ -1,6 +1,6 @@
 import { Plugin } from '@ckeditor/ckeditor5-core';
 import { add } from '@ckeditor/ckeditor5-utils/src/translation-service';
-import { $gettext } from '../../lib/gettext.js';
+import { $gettext } from '../../lib/gettext';
 import A11YDialogEditing from './editing.js';
 import A11YDialogUI from './ui.js';
 
diff --git a/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js b/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js
index a2d207fc5f1..f80d7031705 100644
--- a/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js
+++ b/resources/assets/javascripts/cke/studip-a11y-dialog/ui.js
@@ -1,6 +1,6 @@
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import { Plugin } from '@ckeditor/ckeditor5-core';
-import { $gettext } from '../../lib/gettext.js';
+import { $gettext } from '../../lib/gettext';
 
 const a11yIcon =
     '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54"><path d="M32.5,43h-11a1.5,1.5,0,0,0,0,3h11a1.5,1.5,0,0,0,0-3Z"/><path d="M31.5,48h-9a1.5,1.5,0,0,0,0,3h9a1.5,1.5,0,0,0,0-3Z"/><path d="M27,3a18.54,18.54,0,0,0-2,.11,17,17,0,0,0-6.95,31.37A2,2,0,0,1,19,36.13v3.34A1.5,1.5,0,0,0,20.5,41h13a1.5,1.5,0,0,0,1.5-1.5V36.12a2,2,0,0,1,.9-1.67A17,17,0,0,0,27,3Zm7.33,28.92A5,5,0,0,0,32,36.12V38H22V36.13a5,5,0,0,0-2.33-4.24,14,14,0,0,1,5.7-25.83A14.84,14.84,0,0,1,27,6a14,14,0,0,1,7.33,25.92Z"/><path d="M32.39,9.05A12.51,12.51,0,0,0,27.24,8a12.66,12.66,0,0,0-10.37,5.4,1.73,1.73,0,0,0,.42,2.41,1.69,1.69,0,0,0,1,.32,1.73,1.73,0,0,0,1.42-.74,9.21,9.21,0,0,1,7.54-3.93,9.08,9.08,0,0,1,3.74.8,1.73,1.73,0,1,0,1.41-3.16Z"/><path d="M17,16.31A1.73,1.73,0,0,0,15,17.58a12.38,12.38,0,0,0-.37,3,12.68,12.68,0,0,0,.28,2.67,1.74,1.74,0,0,0,1.69,1.36,1.55,1.55,0,0,0,.37,0,1.74,1.74,0,0,0,1.33-2.06A8.92,8.92,0,0,1,18,20.61a9.08,9.08,0,0,1,.27-2.2A1.74,1.74,0,0,0,17,16.31Z"/></svg>';
diff --git a/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js b/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js
index e50f8c64d8d..0cd43e9d87d 100644
--- a/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js
+++ b/resources/assets/javascripts/cke/studip-quote/StudipBlockQuote.js
@@ -1,6 +1,6 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { $gettext } from '../../lib/gettext.js';
+import { $gettext } from '../../lib/gettext';
 import { Command, icons } from '@ckeditor/ckeditor5-core';
 
 const divideIcon =
diff --git a/resources/assets/javascripts/cke/wiki-link/formview.js b/resources/assets/javascripts/cke/wiki-link/formview.js
index 8d82e25eb20..8a1525a00e1 100644
--- a/resources/assets/javascripts/cke/wiki-link/formview.js
+++ b/resources/assets/javascripts/cke/wiki-link/formview.js
@@ -12,7 +12,7 @@ import {
     addListToDropdown,
 } from '@ckeditor/ckeditor5-ui';
 import { FocusTracker, KeystrokeHandler, Collection, Rect, isVisible } from '@ckeditor/ckeditor5-utils';
-import { $gettext } from '../../lib/gettext.js';
+import { $gettext } from '../../lib/gettext';
 
 export default class WikiLinkFormView extends View {
     constructor(locale) {
diff --git a/resources/assets/javascripts/cke/wiki-link/ui.js b/resources/assets/javascripts/cke/wiki-link/ui.js
index a8e5f89c019..dba6b82e994 100644
--- a/resources/assets/javascripts/cke/wiki-link/ui.js
+++ b/resources/assets/javascripts/cke/wiki-link/ui.js
@@ -1,7 +1,7 @@
 import { Plugin } from '@ckeditor/ckeditor5-core';
 import { createDropdown } from '@ckeditor/ckeditor5-ui';
 import WikiLinkFormView from './formview.js';
-import { $gettext } from '../../lib/gettext.js';
+import { $gettext } from '../../lib/gettext';
 
 const wikiIcon =
     '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54"><path class="cls-1" d="M49.83,15a15.17,15.17,0,0,1-10.17,7.9,31.41,31.41,0,0,1,3.45,11.38C46.63,32.05,53.82,25.94,49.83,15ZM4.17,15c-4,10.94,3.2,17,6.72,19.28A31.41,31.41,0,0,1,14.34,22.9,15.17,15.17,0,0,1,4.17,15ZM27,16c-7.1,0-12.85,10.31-12.85,23h25.7C39.85,26.29,34.1,16,27,16Z"/></svg>';
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index e824775ae92..8981e950ef3 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -77,7 +77,7 @@ import Table from './lib/table.js';
 import TableOfContents from './lib/table-of-contents.js';
 import Tooltip from './lib/tooltip.js';
 import Tour from './lib/tour.js';
-import * as Gettext from './lib/gettext.js';
+import * as Gettext from './lib/gettext';
 import UserFilter from './lib/user_filter.js';
 import wysiwyg from './lib/wysiwyg.js';
 import ScrollToTop from './lib/scroll_to_top.js';
diff --git a/resources/assets/javascripts/jquery-bundle.js b/resources/assets/javascripts/jquery-bundle.js
index bdee32de161..bd1642260ea 100644
--- a/resources/assets/javascripts/jquery-bundle.js
+++ b/resources/assets/javascripts/jquery-bundle.js
@@ -1,6 +1,6 @@
 import 'expose-loader?exposes[]=$&exposes[]=jQuery!jquery';
 
-import { setLocale } from './lib/gettext.js';
+import { setLocale } from './lib/gettext';
 
 import 'jquery-ui/ui/widget.js';
 import 'jquery-ui/ui/position.js';
@@ -76,7 +76,7 @@ import 'blueimp-file-upload/js/jquery.iframe-transport.js';
 
 import './jquery/autoresize.jquery.min.js';
 
-import { $gettext } from './lib/gettext.js';
+import { $gettext } from './lib/gettext';
 
 // Create jQuery "plugin" that just reverses the elements' order. This is
 // neccessary since the navigation is built and afterwards, we need to
diff --git a/resources/assets/javascripts/lib/admission.js b/resources/assets/javascripts/lib/admission.js
index 7cf8c880d3e..df62bbe830d 100644
--- a/resources/assets/javascripts/lib/admission.js
+++ b/resources/assets/javascripts/lib/admission.js
@@ -1,7 +1,7 @@
 /* ------------------------------------------------------------------------
  * Anmeldeverfahren und -sets
  * ------------------------------------------------------------------------ */
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 const Admission = {
diff --git a/resources/assets/javascripts/lib/big_image_handler.js b/resources/assets/javascripts/lib/big_image_handler.js
index 51309979916..55e9b38246c 100644
--- a/resources/assets/javascripts/lib/big_image_handler.js
+++ b/resources/assets/javascripts/lib/big_image_handler.js
@@ -18,7 +18,7 @@
  * @license GPL2 or any later version
  * @since   Stud.IP 3.4
  */
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 var pixelRatio = window.devicePixelRatio || 1,
     dataAttribute = 'big-image-handled';
diff --git a/resources/assets/javascripts/lib/calendar.js b/resources/assets/javascripts/lib/calendar.js
index 2f1cd672a40..2d995b5f5f8 100644
--- a/resources/assets/javascripts/lib/calendar.js
+++ b/resources/assets/javascripts/lib/calendar.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 import eventBus from "./event-bus.ts";
 
 eventBus.on('studip:set-locale', () => {
diff --git a/resources/assets/javascripts/lib/dialog.js b/resources/assets/javascripts/lib/dialog.js
index 8c22d1ced3a..b5cab540604 100644
--- a/resources/assets/javascripts/lib/dialog.js
+++ b/resources/assets/javascripts/lib/dialog.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 import parseOptions from './parse_options.js';
 import extractCallback from './extract_callback.js';
 import Overlay from './overlay.js';
diff --git a/resources/assets/javascripts/lib/files.js b/resources/assets/javascripts/lib/files.js
index 7b628f60311..d05112decd8 100644
--- a/resources/assets/javascripts/lib/files.js
+++ b/resources/assets/javascripts/lib/files.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 import FilesTable from '../../../vue/components/FilesTable.vue';
 
diff --git a/resources/assets/javascripts/lib/folders.js b/resources/assets/javascripts/lib/folders.js
index ced430d0747..6cd23c5fa30 100644
--- a/resources/assets/javascripts/lib/folders.js
+++ b/resources/assets/javascripts/lib/folders.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 const Folders = {
diff --git a/resources/assets/javascripts/lib/forum.js b/resources/assets/javascripts/lib/forum.js
index c2f0d7c0cbf..385ec12b0b3 100644
--- a/resources/assets/javascripts/lib/forum.js
+++ b/resources/assets/javascripts/lib/forum.js
@@ -1,4 +1,4 @@
-import { $gettext } from "./gettext.js";
+import { $gettext } from "./gettext";
 import eventBus from "./event-bus.ts";
 
 eventBus.on('studip:set-locale', () => {
diff --git a/resources/assets/javascripts/lib/gettext.js b/resources/assets/javascripts/lib/gettext.ts
similarity index 73%
rename from resources/assets/javascripts/lib/gettext.js
rename to resources/assets/javascripts/lib/gettext.ts
index 5742466a60c..23daaaa075e 100644
--- a/resources/assets/javascripts/lib/gettext.js
+++ b/resources/assets/javascripts/lib/gettext.ts
@@ -1,6 +1,25 @@
 import { translate } from 'vue-gettext';
-import defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json';
-import eventBus from './event-bus.ts';
+import * as defaultTranslations from '../../../../locale/de/LC_MESSAGES/js-resources.json';
+import eventBus from './event-bus';
+
+interface StringDict {
+    [key: string]: string;
+}
+
+interface InstalledLanguage {
+    name: string;
+    selected: boolean;
+}
+
+interface InstalledLanguages {
+    [key: string]: InstalledLanguage;
+}
+
+type TranslationDict = StringDict;
+
+interface TranslationDicts {
+    [key: string]: TranslationDict | null;
+}
 
 const DEFAULT_LANG = 'de_DE';
 const DEFAULT_LANG_NAME = 'Deutsch';
@@ -24,7 +43,7 @@ async function setLocale(locale = getInitialLocale()) {
 
     state.locale = locale;
     if (state.translations[state.locale] === null) {
-        const translations = await getTranslations(state.locale);
+        const translations: TranslationDict = await getTranslations(state.locale);
         state.translations[state.locale] = translations;
     }
 
@@ -43,7 +62,7 @@ function getVueConfig() {
         memo[lang] = name;
 
         return memo;
-    }, {});
+    }, {} as StringDict);
 
     return {
         availableLanguages,
@@ -55,11 +74,11 @@ function getVueConfig() {
 }
 
 function getInitialState() {
-    const translations = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => {
+    const translations: TranslationDicts = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => {
         memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null;
 
         return memo;
-    }, {});
+    }, {} as TranslationDicts);
 
     return {
         locale: DEFAULT_LANG,
@@ -77,11 +96,11 @@ function getInitialLocale() {
     return DEFAULT_LANG;
 }
 
-function getInstalledLanguages() {
+function getInstalledLanguages(): InstalledLanguages {
     return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } };
 }
 
-async function getTranslations(locale) {
+async function getTranslations(locale: string): Promise<TranslationDict> {
     try {
         const language = locale.split(/[_-]/)[0];
         const translation = await import(`../../../../locale/${language}/LC_MESSAGES/js-resources.json`);
diff --git a/resources/assets/javascripts/lib/instschedule.js b/resources/assets/javascripts/lib/instschedule.js
index af438c29d9c..d925bfbc0a9 100644
--- a/resources/assets/javascripts/lib/instschedule.js
+++ b/resources/assets/javascripts/lib/instschedule.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 const Instschedule = {
diff --git a/resources/assets/javascripts/lib/jsupdater.js b/resources/assets/javascripts/lib/jsupdater.js
index 7888f29218b..5069af0be09 100644
--- a/resources/assets/javascripts/lib/jsupdater.js
+++ b/resources/assets/javascripts/lib/jsupdater.js
@@ -10,7 +10,7 @@
  *
  * Refer to the according function definitions for further info.
  * ------------------------------------------------------------------------ */
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 let active = false;
diff --git a/resources/assets/javascripts/lib/lightbox.js b/resources/assets/javascripts/lib/lightbox.js
index 134cfca8586..09bfda29e67 100644
--- a/resources/assets/javascripts/lib/lightbox.js
+++ b/resources/assets/javascripts/lib/lightbox.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 function sprintf(string) {
diff --git a/resources/assets/javascripts/lib/messages.js b/resources/assets/javascripts/lib/messages.js
index dbb27939607..7ce5328b254 100644
--- a/resources/assets/javascripts/lib/messages.js
+++ b/resources/assets/javascripts/lib/messages.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Markup from './markup.js';
 
 const Messages = {
diff --git a/resources/assets/javascripts/lib/multi_person_search.js b/resources/assets/javascripts/lib/multi_person_search.js
index f5ba046c164..b876bc979fc 100644
--- a/resources/assets/javascripts/lib/multi_person_search.js
+++ b/resources/assets/javascripts/lib/multi_person_search.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 const MultiPersonSearch = {
     init: function() {
diff --git a/resources/assets/javascripts/lib/multi_select.js b/resources/assets/javascripts/lib/multi_select.js
index b4abeb9c1ed..6c1b3875166 100644
--- a/resources/assets/javascripts/lib/multi_select.js
+++ b/resources/assets/javascripts/lib/multi_select.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 /**
  * Turns a select-box into an easy to use multiple select-box
diff --git a/resources/assets/javascripts/lib/oer.js b/resources/assets/javascripts/lib/oer.js
index 112d155a5ce..17f0186ad11 100644
--- a/resources/assets/javascripts/lib/oer.js
+++ b/resources/assets/javascripts/lib/oer.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 const OER = {
     periodicalPushData: function () {
diff --git a/resources/assets/javascripts/lib/overlapping.js b/resources/assets/javascripts/lib/overlapping.js
index 73ab32fa52e..a6aa4b8ae38 100644
--- a/resources/assets/javascripts/lib/overlapping.js
+++ b/resources/assets/javascripts/lib/overlapping.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 const Overlapping = {
 
@@ -91,4 +91,4 @@ const Overlapping = {
     }
 };
 
-export default Overlapping;
\ No newline at end of file
+export default Overlapping;
diff --git a/resources/assets/javascripts/lib/overlay.js b/resources/assets/javascripts/lib/overlay.js
index 52d1c949d5b..ffe8ed144fe 100644
--- a/resources/assets/javascripts/lib/overlay.js
+++ b/resources/assets/javascripts/lib/overlay.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 const Overlay = {
     delay: 300,
diff --git a/resources/assets/javascripts/lib/qr_code.js b/resources/assets/javascripts/lib/qr_code.js
index 3db4fd8703c..ada0cb33f55 100644
--- a/resources/assets/javascripts/lib/qr_code.js
+++ b/resources/assets/javascripts/lib/qr_code.js
@@ -1,4 +1,4 @@
-import { $gettext } from "./gettext.js";
+import { $gettext } from "./gettext";
 import Dialog from "./dialog.js";
 
 const QRCode = {
diff --git a/resources/assets/javascripts/lib/questionnaire.js b/resources/assets/javascripts/lib/questionnaire.js
index 8fbbbb0775f..2bca8c628aa 100644
--- a/resources/assets/javascripts/lib/questionnaire.js
+++ b/resources/assets/javascripts/lib/questionnaire.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 import md5 from 'md5';
 //import html2canvas from "html2canvas";
 //import {jsPDF} from "jspdf";
diff --git a/resources/assets/javascripts/lib/quick_search.js b/resources/assets/javascripts/lib/quick_search.js
index 806debd5f50..627bffacb39 100644
--- a/resources/assets/javascripts/lib/quick_search.js
+++ b/resources/assets/javascripts/lib/quick_search.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 /* ------------------------------------------------------------------------
  * QuickSearch inputs
diff --git a/resources/assets/javascripts/lib/raumzeit.js b/resources/assets/javascripts/lib/raumzeit.js
index 5cd5e55118f..c28dbae280d 100644
--- a/resources/assets/javascripts/lib/raumzeit.js
+++ b/resources/assets/javascripts/lib/raumzeit.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 const Raumzeit = {
     disableBookableRooms: function(icon) {
diff --git a/resources/assets/javascripts/lib/register.js b/resources/assets/javascripts/lib/register.js
index da81132aa91..de7b66684dc 100644
--- a/resources/assets/javascripts/lib/register.js
+++ b/resources/assets/javascripts/lib/register.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 const register = {
     re_username: null,
diff --git a/resources/assets/javascripts/lib/resources.js b/resources/assets/javascripts/lib/resources.js
index 2375eeeab17..9acb2e375b8 100644
--- a/resources/assets/javascripts/lib/resources.js
+++ b/resources/assets/javascripts/lib/resources.js
@@ -1,4 +1,4 @@
-import { $gettext } from '../lib/gettext.js';
+import { $gettext } from '../lib/gettext';
 
 class Resources
 {
diff --git a/resources/assets/javascripts/lib/schedule.js b/resources/assets/javascripts/lib/schedule.js
index b7c9d370034..f3e5123fd45 100644
--- a/resources/assets/javascripts/lib/schedule.js
+++ b/resources/assets/javascripts/lib/schedule.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Calendar from './calendar.js';
 import Dialog from './dialog.js';
 
diff --git a/resources/assets/javascripts/lib/tour.js b/resources/assets/javascripts/lib/tour.js
index b93be078395..8094b2b08a6 100644
--- a/resources/assets/javascripts/lib/tour.js
+++ b/resources/assets/javascripts/lib/tour.js
@@ -1,4 +1,4 @@
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 
 /* ------------------------------------------------------------------------
  * Stud.IP Tour
diff --git a/resources/assets/javascripts/lib/user_filter.js b/resources/assets/javascripts/lib/user_filter.js
index 450af348e0e..25b8488e0e2 100644
--- a/resources/assets/javascripts/lib/user_filter.js
+++ b/resources/assets/javascripts/lib/user_filter.js
@@ -1,7 +1,7 @@
 /* ------------------------------------------------------------------------
  * Bedingungen zur Auswahl von Stud.IP-Nutzern
  * ------------------------------------------------------------------------ */
-import { $gettext } from './gettext.js';
+import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 const UserFilter = {
diff --git a/resources/assets/javascripts/mvv.js b/resources/assets/javascripts/mvv.js
index 12d26532665..a339624b608 100644
--- a/resources/assets/javascripts/mvv.js
+++ b/resources/assets/javascripts/mvv.js
@@ -1,4 +1,4 @@
-import { $gettext } from './lib/gettext.js';
+import { $gettext } from './lib/gettext';
 
 jQuery(function ($) {
     $(document).on('click', 'a.mvv-load-in-new-row', function () {
diff --git a/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js b/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js
index 2462ff990f1..adde0ceeb14 100644
--- a/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js
+++ b/resources/assets/javascripts/studip-jquery.multi-select.tweaks.js
@@ -1,4 +1,4 @@
-import { $gettext } from './lib/gettext.js';
+import { $gettext } from './lib/gettext';
 
 
 /**
diff --git a/resources/assets/javascripts/studip-ui.js b/resources/assets/javascripts/studip-ui.js
index 60a3cfaf656..f5812952a85 100644
--- a/resources/assets/javascripts/studip-ui.js
+++ b/resources/assets/javascripts/studip-ui.js
@@ -1,4 +1,4 @@
-import { $gettext } from './lib/gettext.js';
+import { $gettext } from './lib/gettext';
 import eventBus from "./lib/event-bus.ts";
 
 /**
diff --git a/resources/vue-gettext.d.ts b/resources/vue-gettext.d.ts
new file mode 100644
index 00000000000..b3f4c6611cb
--- /dev/null
+++ b/resources/vue-gettext.d.ts
@@ -0,0 +1,17 @@
+declare module "vue-gettext" {
+    import GettextPlugin from 'vue-gettext';
+
+    declare namespace translate {
+        function getTranslation(msgid: any, n?: number, context?: any, defaultPlural?: any, language?: string): any;
+        function gettext(msgid: any, language?: string): any;
+        function pgettext(context: any, msgid: any, language?: string): any;
+        function ngettext(msgid: any, plural: any, n: any, language?: string): any;
+        function npgettext(context: any, msgid: any, plural: any, n: any, language?: string): any;
+        function initTranslations(translations: any, config: any): void;
+        const gettextInterpolate: any;
+    }
+
+    export { translate };
+
+    export default GettextPlugin;
+}
diff --git a/resources/vue/components/StudipDate.vue b/resources/vue/components/StudipDate.vue
new file mode 100644
index 00000000000..2e30b9d250a
--- /dev/null
+++ b/resources/vue/components/StudipDate.vue
@@ -0,0 +1,27 @@
+<template>
+    <time :datetime="date.toISOString()">{{ formatted }}</time>
+</template>
+
+<script>
+function formatDate(date) {
+    return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear();
+}
+
+function pad(what) {
+    return what.toString().padStart(2, '0');
+}
+
+export default {
+    props: {
+        date: {
+            type: Date,
+            required: true,
+        },
+    },
+    computed: {
+        formatted() {
+            return formatDate(this.date);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/CoursewareDashboardStudents.vue
deleted file mode 100644
index bac31a6f0fc..00000000000
--- a/resources/vue/components/courseware/CoursewareDashboardStudents.vue
+++ /dev/null
@@ -1,481 +0,0 @@
-<template>
-    <div class="cw-dashboard-students-wrapper">
-        <table v-if="tasks.length > 0" class="default">
-            <colgroup>
-                <col />
-            </colgroup>
-            <thead>
-                <tr class="sortable">
-                    <th>{{ $gettext('Status') }}</th>
-                    <th :class="getSortClass('task-title')" @click="sort('task-title')">
-                        {{ $gettext('Aufgabentitel') }}
-                    </th>
-                    <th :class="getSortClass('solver-name')" @click="sort('solver-name')">
-                        {{ $gettext('Teilnehmende/Gruppen') }}
-                    </th>
-                    <th class="responsive-hidden" :class="getSortClass('page-title')" @click="sort('page-title')">
-                        {{ $gettext('Seite') }}
-                    </th>
-                    <th :class="getSortClass('progress')" @click="sort('progress')">
-                        {{ $gettext('bearbeitet') }}
-                    </th>
-                    <th :class="getSortClass('submission-date')" @click="sort('submission-date')">
-                        {{ $gettext('Abgabefrist') }}
-                    </th>
-                    <th>{{ $gettext('Abgabe') }}</th>
-                    <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th>
-                    <th class="responsive-hidden feedback">{{ $gettext('Anmerkungen') }}</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr v-for="{ task, taskGroup, status, element, user, group, feedback } in tasks" :key="task.id">
-                    <td>
-                        <studip-icon
-                            v-if="status.shape !== undefined"
-                            :shape="status.shape"
-                            :role="status.role"
-                            :title="status.description"
-                            aria-hidden="true"
-                        />
-                        <span class="sr-only">{{ status.description }}</span>
-                    </td>
-                    <td>
-                        {{ taskGroup && taskGroup.attributes.title }}
-                    </td>
-                    <td>
-                        <span v-if="user">
-                            <studip-icon 
-                                shape="person2"
-                                role="info"
-                                aria-hidden="true"
-                                :title="$gettext('Teilnehmende Person')" 
-                            />
-                            <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span>
-                            {{ user.attributes['formatted-name'] }}
-
-                        </span>
-                        <span v-if="group">
-                            <studip-icon
-                                shape="group2"
-                                role="info"
-                                aria-hidden="true"
-                                :title="$gettext('Gruppe')"
-                            />
-                            <span class="sr-only">{{ $gettext('Gruppe') }}</span>
-                            {{ group.attributes['name'] }}
-
-                        </span>
-                    </td>
-                    <td class="responsive-hidden">
-                        <a v-if="task.attributes.submitted" :href="getLinkToElement(element)">
-                            {{ element.attributes.title }}
-                        </a>
-                        <span v-else>{{ element.attributes.title }}</span>
-                    </td>
-                    <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td>
-                    <td>{{ getReadableDate(task.attributes['submission-date']) }}</td>
-                    <td>
-                        <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" />
-                    </td>
-                    <td class="responsive-hidden">
-                        <button
-                            v-show="task.attributes.renewal === 'pending'"
-                            class="button"
-                            @click="solveRenewalRequest(task)"
-                        >
-                            {{ $gettext('Anfrage bearbeiten') }}
-                        </button>
-                        <span v-show="task.attributes.renewal === 'declined'">
-                            <studip-icon shape="decline" role="status-red" />
-                            {{ $gettext('Anfrage abgelehnt') }}
-                        </span>
-                        <span v-show="task.attributes.renewal === 'granted'">
-                            {{ $gettext('verlängert bis') }}:
-                            {{ getReadableDate(task.attributes['renewal-date']) }}
-                        </span>
-                        <studip-icon
-                            v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'"
-                            :title="$gettext('Anfrage bearbeiten')"
-                            class="edit"
-                            shape="edit"
-                            role="clickable"
-                            @click="solveRenewalRequest(task)"
-                        />
-                    </td>
-                    <td class="responsive-hidden">
-                        <span
-                            v-if="feedback"
-                            :title="
-                                $gettext('Anmerkung geschrieben am:') +
-                                ' ' +
-                                getReadableDate(feedback.attributes['chdate'])
-                            "
-                        >
-                            <studip-icon shape="accept" role="status-green" />
-                            {{ $gettext('Anmerkung gegeben') }}
-                            <studip-icon
-                                :title="$gettext('Anmerkung bearbeiten')"
-                                class="edit"
-                                shape="edit"
-                                role="clickable"
-                                @click="editFeedback(feedback)"
-                            />
-                        </span>
-
-                        <button
-                            v-show="!feedback && task.attributes.submitted"
-                            class="button"
-                            @click="addFeedback(task)"
-                        >
-                            {{ $gettext('Anmerkung geben') }}
-                        </button>
-                    </td>
-                </tr>
-            </tbody>
-        </table>
-        <div v-else>
-            <courseware-companion-box 
-                mood="pointing"
-                :msgCompanion="$gettext('Es wurden bisher keine Aufgaben gestellt.')"
-            >
-            </courseware-companion-box>
-        </div>
-        <studip-dialog
-            v-if="showRenewalDialog"
-            :title="text.renewalDialog.title"
-            :confirmText="text.renewalDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.renewalDialog.close"
-            closeClass="cancel"
-            height="350"
-            @close="
-                showRenewalDialog = false;
-                currentDialogTask = {};
-            "
-            @confirm="updateRenewal"
-        >
-            <template v-slot:dialogContent>
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Fristverlängerung') }}
-                        <select v-model="currentDialogTask.attributes.renewal">
-                            <option value="declined">
-                                {{ $gettext('ablehnen') }}
-                            </option>
-                            <option value="granted">
-                                {{ $gettext('gewähren') }}
-                            </option>
-                        </select>
-                    </label>
-                    <label v-if="currentDialogTask.attributes.renewal === 'granted'">
-                        {{ $gettext('neue Frist') }}
-                        <courseware-date-input v-model="currentDialogTask.attributes['renewal-date']" class="size-l" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <studip-dialog
-            v-if="showEditFeedbackDialog"
-            :title="text.editFeedbackDialog.title"
-            :confirmText="text.editFeedbackDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.editFeedbackDialog.close"
-            closeClass="cancel"
-            height="420"
-            @close="
-                showEditFeedbackDialog = false;
-                currentDialogFeedback = {};
-            "
-            @confirm="updateFeedback"
-        >
-            <template v-slot:dialogContent>
-                <courseware-companion-box
-                    v-if="currentDialogFeedback.attributes.content === ''"
-                    mood="pointing"
-                    :msgCompanion="
-                        $gettext('Sie haben keine Anmerkungen geschrieben, beim Speichern wird diese Anmerkung gelöscht!')
-                    "
-                />
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Anmerkung') }}
-                        <textarea v-model="currentDialogFeedback.attributes.content" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <studip-dialog
-            v-if="showAddFeedbackDialog"
-            :title="text.addFeedbackDialog.title"
-            :confirmText="text.addFeedbackDialog.confirm"
-            confirmClass="accept"
-            :closeText="text.addFeedbackDialog.close"
-            closeClass="cancel"
-            @close="
-                showAddFeedbackDialog = false;
-                currentDialogFeedback = {};
-            "
-            @confirm="createFeedback"
-        >
-            <template v-slot:dialogContent>
-                <form class="default" @submit.prevent="">
-                    <label>
-                        {{ $gettext('Anmerkung') }}
-                        <textarea v-model="currentDialogFeedback.attributes.content" />
-                    </label>
-                </form>
-            </template>
-        </studip-dialog>
-        <courseware-tasks-dialog-distribute v-if="showTasksDistributeDialog" @newtask="reloadTasks"/>
-    </div>
-</template>
-
-<script>
-import StudipIcon from './../StudipIcon.vue';
-import StudipDialog from './../StudipDialog.vue';
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import CoursewareDateInput from './layouts/CoursewareDateInput.vue';
-import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
-import taskHelperMixin from '../../mixins/courseware/task-helper.js';
-import { mapActions, mapGetters } from 'vuex';
-
-
-export default {
-    name: 'courseware-dashboard-students',
-    mixins: [taskHelperMixin],
-    components: {
-        CoursewareCompanionBox,
-        CoursewareDateInput,
-        StudipIcon,
-        StudipDialog,
-        CoursewareTasksDialogDistribute,
-    },
-    data() {
-        return {
-            showRenewalDialog: false,
-            showAddFeedbackDialog: false,
-            showEditFeedbackDialog: false,
-            currentDialogTask: {},
-            currentDialogFeedback: {},
-            text: {
-                renewalDialog: {
-                    title: this.$gettext('Verlängerungsanfrage bearbeiten'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-                editFeedbackDialog: {
-                    title: this.$gettext('Anmerkung zur Aufgabe ändern'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-                addFeedbackDialog: {
-                    title: this.$gettext('Anmerkung zur Aufgabe erstellen'),
-                    confirm: this.$gettext('Speichern'),
-                    close: this.$gettext('Schließen'),
-                },
-            },
-            sortBy: 'task-title',
-            sortASC: true,
-        };
-    },
-    computed: {
-        ...mapGetters({
-            context: 'context',
-            allTasks: 'courseware-tasks/all',
-            userById: 'users/byId',
-            statusGroupById: 'status-groups/byId',
-            getElementById: 'courseware-structural-elements/byId',
-            getFeedbackById: 'courseware-task-feedback/byId',
-            relatedTaskGroups: 'courseware-task-groups/related',
-            showTasksDistributeDialog: 'showTasksDistributeDialog'
-        }),
-        tasks() {
-            const tasks = this.allTasks.map((task) => {
-                const result = {
-                    task,
-                    taskGroup: this.relatedTaskGroups({ parent: task, relationship: 'task-group' }),
-                    status: this.getStatus(task),
-                    element: this.getElementById({ id: task.relationships['structural-element'].data.id }),
-                    user: null,
-                    group: null,
-                    feedback: null,
-                    solverName: null
-                };
-                let solver = task.relationships.solver.data;
-                if (solver.type === 'users') {
-                    result.user = this.userById({ id: solver.id });
-                    result.solverName = result.user.attributes['formatted-name'];
-                }
-                if (solver.type === 'status-groups') {
-                    result.group = this.statusGroupById({ id: solver.id });
-                    result.solverName = result.group.attributes['name'];
-                }
-
-                const feedbackId = task.relationships['task-feedback'].data?.id;
-                if (feedbackId) {
-                    result.feedback = this.getFeedbackById({ id: feedbackId });
-                }
-
-                return result;
-            });
-
-            return this.sortTasks(tasks);
-        },
-        managerUrl() {
-            return STUDIP.URLHelper.getURL('dispatch.php/course/courseware/manager', {cid: this.context.id});
-        }
-    },
-    methods: {
-        ...mapActions({
-            updateTask: 'updateTask',
-            createTaskFeedback: 'createTaskFeedback',
-            updateTaskFeedback: 'updateTaskFeedback',
-            deleteTaskFeedback: 'deleteTaskFeedback',
-            loadRemoteCoursewareStructure: 'loadRemoteCoursewareStructure',
-            copyStructuralElement: 'copyStructuralElement',
-            companionSuccess: 'companionSuccess',
-            companionError: 'companionError',
-            loadAllTasks: 'courseware-tasks/loadAll'
-        }),
-        addFeedback(task) {
-            this.currentDialogFeedback.attributes = {};
-            this.currentDialogFeedback.attributes.content = '';
-            this.currentDialogFeedback.relationships = {};
-            this.currentDialogFeedback.relationships.task = {};
-            this.currentDialogFeedback.relationships.task.data = {};
-            this.currentDialogFeedback.relationships.task.data.id = task.id;
-            this.currentDialogFeedback.relationships.task.data.type = task.type;
-            this.showAddFeedbackDialog = true;
-        },
-        createFeedback() {
-            if (this.currentDialogFeedback.attributes.content === '') {
-                this.companionError({
-                    info: this.$gettext('Bitte schreiben Sie eine Anmerkung.'),
-                });
-                return false;
-            }
-            this.showAddFeedbackDialog = false;
-            this.createTaskFeedback({
-                taskFeedback: this.currentDialogFeedback,
-            });
-            this.currentDialogFeedback = {};
-        },
-        editFeedback(feedback) {
-            this.currentDialogFeedback = _.cloneDeep(feedback);
-            this.showEditFeedbackDialog = true;
-        },
-        async updateFeedback() {
-            this.showEditFeedbackDialog = false;
-            let attributes = {};
-            attributes.content = this.currentDialogFeedback.attributes.content;
-            if (attributes.content === '') {
-                await this.deleteTaskFeedback({
-                    taskFeedbackId: this.currentDialogFeedback.id,
-                });
-                this.companionSuccess({
-                    info: this.$gettext('Anmerkung wurde gelöscht.'),
-                });
-            } else {
-                await this.updateTaskFeedback({
-                    attributes: attributes,
-                    taskFeedbackId: this.currentDialogFeedback.id,
-                });
-                this.companionSuccess({
-                    info: this.$gettext('Anmerkung wurde gespeichert.'),
-                });
-            }
-
-            this.currentDialogFeedback = {};
-        },
-        solveRenewalRequest(task) {
-            this.currentDialogTask = _.cloneDeep(task);
-            this.currentDialogTask.attributes['renewal-date'] = new Date().toISOString();
-            this.showRenewalDialog = true;
-        },
-        updateRenewal() {
-            this.showRenewalDialog = false;
-            let attributes = {};
-            attributes.renewal = this.currentDialogTask.attributes.renewal;
-            if (attributes.renewal === 'granted') {
-                attributes['renewal-date'] = new Date(this.currentDialogTask.attributes['renewal-date'] || Date.now()).toISOString();
-            }
-
-            this.updateTask({
-                attributes: attributes,
-                taskId: this.currentDialogTask.id,
-            });
-            this.currentDialogTask = {};
-        },
-        reloadTasks() {
-            this.loadAllTasks({ 
-                options: {
-                    'filter[cid]': this.context.id,
-                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer'
-                }
-            });
-        },
-        getSortClass(col) {
-            if (col === this.sortBy) {
-                return this.sortASC ? 'sortasc' : 'sortdesc';
-            }
-        },
-        sort(sortBy) {
-            if (this.sortBy === sortBy) {
-                this.sortASC = !this.sortASC;
-            } else {
-                this.sortBy = sortBy;
-            }
-        },
-        sortTasks(tasks) {
-            switch (this.sortBy) {
-                case 'task-title':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.taskGroup.attributes.title < b.taskGroup.attributes.title ? -1 : 1;
-                        } else {
-                            return a.taskGroup.attributes.title > b.taskGroup.attributes.title ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'solver-name':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.solverName < b.solverName ? -1 : 1;
-                        } else {
-                            return a.solverName > b.solverName ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'page-title':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.element.attributes.title < b.element.attributes.title ? -1 : 1;
-                        } else {
-                            return a.element.attributes.title > b.element.attributes.title ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'progress':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return a.task.attributes.progress < b.task.attributes.progress ? -1 : 1;
-                        } else {
-                            return a.task.attributes.progress > b.task.attributes.progress ? -1 : 1;
-                        }
-                    });
-                    break;
-                case 'submission-date':
-                    tasks = tasks.sort((a, b) => {
-                        if (this.sortASC) {
-                            return new Date(a.task.attributes['submission-date']) - new Date(b.task.attributes['submission-date']);
-                        } else {
-                            return new Date(b.task.attributes['submission-date']) - new Date(a.task.attributes['submission-date']);
-                        }
-                    });
-                    break;
-            }
-            return tasks;
-        },
-    },
-};
-</script>
diff --git a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
index eefc0fb5d0e..e79df40ffea 100644
--- a/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
+++ b/resources/vue/components/courseware/layouts/CoursewareCollapsibleBox.vue
@@ -2,7 +2,8 @@
     <div class="cw-collapsible" :class="{ 'cw-collapsible-open': isOpen }">
         <a href="#" :aria-expanded="isOpen" @click.prevent="isOpen = !isOpen">
             <header :class="{ 'cw-collapsible-open': isOpen }" class="cw-collapsible-title">
-                <studip-icon v-if="icon" :shape="icon" /> {{ title }}
+                <studip-icon v-if="icon" :shape="icon" />
+                <slot name="title" :is-open="isOpen">{{ title }}</slot>
             </header>
         </a>
         <div v-if="isOpen" class="cw-collapsible-content" :class="{ 'cw-collapsible-content-open': isOpen }">
diff --git a/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
new file mode 100644
index 00000000000..2d7c28104e9
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/AddFeedbackDialog.vue
@@ -0,0 +1,48 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Feedback zur Aufgabe geben')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="$emit('close')"
+        @confirm="create"
+    >
+        <template #dialogContent>
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Feedback') }}
+                    <textarea v-model="localContent" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+export default {
+    props: ['content'],
+    data: () => ({
+        localContent: '',
+    }),
+    methods: {
+        resetLocalVars() {
+            this.localContent = this.content;
+        },
+        create() {
+            this.$emit('create', { content: this.localContent });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        content(newValue) {
+            if (newValue !== this.localContent) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
new file mode 100644
index 00000000000..426b0cbff4b
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -0,0 +1,222 @@
+<template>
+    <div class="cw-dashboard-students-wrapper">
+        <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
+            <template #buttons>
+                <router-link :to="{ name: 'task-groups-index' }">
+                    <StudipIcon shape="category-task" :size="24" />
+                </router-link>
+            </template>
+            <template #breadcrumbList>
+                <li>
+                    {{ $gettext('Aufgaben') }}
+                </li>
+            </template>
+        </CoursewareRibbon>
+        <table class="default" v-if="taskGroups.length">
+            <thead>
+                <tr class="sortable">
+                    <th>
+                        {{ $gettext('Status') }}
+                    </th>
+                    <th :class="getSortClass('task-group-title')" @click="sort('task-group-title')">
+                        {{ $gettext('Titel') }}
+                    </th>
+                    <th :class="getSortClass('end-date')" @click="sort('end-date')">
+                        {{ $gettext('Bearbeitungszeit') }}
+                    </th>
+                    <th class="actions">{{ $gettext('Aktionen') }}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="(taskGroup, index) in sortedTaskGroups" :key="index">
+                    <td>
+                        <StudipIcon
+                            :shape="status(taskGroup).shape"
+                            :role="status(taskGroup).role"
+                            :title="status(taskGroup).description"
+                            aria-hidden="true"
+                        />
+                        <span class="sr-only">{{ status(taskGroup).description }}</span>
+                    </td>
+                    <td>
+                        <router-link :to="{ name: 'task-groups-show', params: { id: taskGroup.id } }">{{
+                            taskGroup.attributes.title
+                        }}</router-link>
+                    </td>
+                    <td>
+                        <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - <StudipDate
+                            :date="new Date(taskGroup.attributes['end-date'])"
+                        />
+                    </td>
+                    <td class="actions">
+                        <StudipActionMenu
+                            :items="getTaskGroupMenuItems(taskGroup)"
+                            @addsolvers="onShowAddSolvers(taskGroup)"
+                            @deadline="onShowModifyDeadline(taskGroup)"
+                            @delete="onShowDeleteDialog(taskGroup)"
+                        />
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+
+        <CompanionBox v-else-if="!tasksLoading" :msgCompanion="$gettext('Es wurden noch keine Aufgaben verteilt.')">
+            <template #companionActions>
+                <button @click="setShowTasksDistributeDialog(true)" type="button" class="button">
+                    {{ $gettext('Aufgabe verteilen') }}
+                </button>
+            </template>
+        </CompanionBox>
+
+        <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" />
+    </div>
+</template>
+
+<script>
+import _ from 'lodash';
+import { mapActions, mapGetters } from 'vuex';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
+import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDate from '../../StudipDate.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
+import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
+import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
+import { getStatus } from './task-groups-helper.js';
+
+export default {
+    name: 'courseware-dashboard-students',
+    components: {
+        CompanionBox,
+        CoursewareRibbon,
+        CoursewareTasksDialogDistribute,
+        StudipActionMenu,
+        StudipDate,
+        StudipIcon,
+        TaskGroupsAddSolversDialog,
+        TaskGroupsDeleteDialog,
+        TaskGroupsModifyDeadlineDialog,
+    },
+    data: () => ({
+        selectedTaskGroup: null,
+        sortBy: 'end-date',
+        sortAsc: false,
+    }),
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog',
+            showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog',
+            showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog',
+            showTasksDistributeDialog: 'tasks/showTasksDistributeDialog',
+            taskGroupsByCid: 'tasks/taskGroupsByCid',
+            tasksLoading: 'courseware-tasks/isLoading',
+        }),
+        sortedTaskGroups() {
+            const sorters = {
+                'task-group-title': (taskGroup) => taskGroup.attributes.title,
+                'end-date': (taskGroup) => new Date(taskGroup.attributes['end-date']),
+            };
+
+            return _.chain(this.taskGroups)
+                .sortBy([sorters[this.sortBy]])
+                .thru((sorted) => (this.sortAsc ? sorted : _.reverse(sorted)))
+                .value();
+        },
+        taskGroups() {
+            return this.taskGroupsByCid(this.context.id);
+        },
+    },
+    methods: {
+        ...mapActions({
+            loadAllTasks: 'courseware-tasks/loadAll',
+            setShowTaskGroupsAddSolversDialog: 'tasks/setShowTaskGroupsAddSolversDialog',
+            setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog',
+            setShowTaskGroupsModifyDeadlineDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
+        }),
+        getSortClass(col) {
+            if (col === this.sortBy) {
+                return this.sortAsc ? 'sortasc' : 'sortdesc';
+            }
+            return '';
+        },
+        getTaskGroupMenuItems(taskGroup) {
+            let menuItems = [];
+
+            const isBeforeEndDate = new Date() < new Date(taskGroup.attributes['end-date']);
+            if (isBeforeEndDate) {
+                menuItems.push({
+                    id: 'add-solvers',
+                    label: this.$gettext('Teilnehmende hinzufügen'),
+                    icon: 'add',
+                    emit: 'addsolvers'
+                });
+                menuItems.push({
+                    id: 'modify-deadline',
+                    label: this.$gettext('Bearbeitungszeit verlängern'),
+                    icon: 'date',
+                    emit: 'deadline'
+                });
+            }
+
+            menuItems.push({
+                id: 'delete',
+                label: this.$gettext('Aufgabe löschen'),
+                icon: 'trash',
+                emit: 'delete',
+            });
+
+            return menuItems;
+        },
+        onShowAddSolvers(taskGroup) {
+            this.selectedTaskGroup = taskGroup;
+            this.setShowTaskGroupsAddSolversDialog(true);
+        },
+        onShowDeleteDialog(taskGroup) {
+            this.selectedTaskGroup = taskGroup;
+            this.setShowTaskGroupsDeleteDialog(true);
+        },
+        onShowModifyDeadline(taskGroup) {
+            this.selectedTaskGroup = taskGroup;
+            this.setShowTaskGroupsModifyDeadlineDialog(true);
+        },
+        reloadTasks() {
+            this.loadAllTasks({
+                options: {
+                    'filter[cid]': this.context.id,
+                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                },
+            });
+        },
+        sort(sortBy) {
+            if (this.sortBy === sortBy) {
+                this.sortAsc = !this.sortAsc;
+            } else {
+                this.sortBy = sortBy;
+            }
+        },
+        status: getStatus,
+    },
+};
+</script>
+
+<style scoped>
+.cw-dashboard-students-wrapper >>> .cw-ribbon-nav {
+    min-width: 24px;
+    padding: 0 1em;
+    height: 24px;
+    margin-top: 2px;
+}
+th {
+    cursor: pointer;
+}
+th:is(:first-child,:last-child) {
+    cursor: not-allowed;
+}
+</style>
diff --git a/resources/vue/components/courseware/CoursewareDashboardTasks.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
similarity index 97%
rename from resources/vue/components/courseware/CoursewareDashboardTasks.vue
rename to resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
index 6de9c13882b..9c9e298eff2 100644
--- a/resources/vue/components/courseware/CoursewareDashboardTasks.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardTasks.vue
@@ -102,11 +102,11 @@
     </div>
 </template>
 <script>
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import StudipIcon from '../StudipIcon.vue';
-import StudipActionMenu from '../StudipActionMenu.vue';
-import StudipDialog from '../StudipDialog.vue';
-import taskHelperMixin from '../../mixins/courseware/task-helper.js';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipIcon from '../../StudipIcon.vue';
+import StudipActionMenu from '../../StudipActionMenu.vue';
+import StudipDialog from '../../StudipDialog.vue';
+import taskHelperMixin from '../../../mixins/courseware/task-helper.js';
 import { mapActions, mapGetters } from 'vuex';
 
 export default {
diff --git a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
similarity index 92%
rename from resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
rename to resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
index 79c8cacac60..e88bd04da57 100644
--- a/resources/vue/components/courseware/CoursewareTasksDialogDistribute.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue
@@ -27,10 +27,10 @@
                             :key="'label-' + unit.id"
                             :for="'cw-task-dist-source-unit' + unit.id"
                         >
-                            <div class="icon"><studip-icon shape="courseware" size="32" /></div>
+                            <div class="icon"><studip-icon shape="courseware" :size="32" /></div>
                             <div class="text">{{ unit.element.attributes.title }}</div>
-                            <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" />
-                            <studip-icon shape="check-circle" size="24" class="check" />
+                            <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" />
+                            <studip-icon shape="check-circle" :size="24" class="check" />
                         </label>
                     </template>
                 </fieldset>
@@ -62,10 +62,15 @@
                     <span aria-hidden="true" class="wizard-required">*</span>
                     <input type="text" v-model="taskTitle" required />
                 </label>
+                <label>
+                    <span>{{ $gettext('Startdatum') }}</span>
+                    <span aria-hidden="true" class="wizard-required">*</span>
+                    <input type="date" v-model="startDate" required />
+                </label>
                 <label>
                     <span>{{ $gettext('Abgabefrist') }}</span>
                     <span aria-hidden="true" class="wizard-required">*</span>
-                    <input type="date" v-model="submissionDate" />
+                    <input type="date" v-model="endDate" :min="startDate" required />
                 </label>
                 <label>
                     {{ $gettext('Inhalte ergänzen') }}
@@ -99,10 +104,10 @@
                             :key="'label-' + unit.id"
                             :for="'cw-task-dist-target-unit' + unit.id"
                         >
-                            <div class="icon"><studip-icon shape="courseware" size="32" /></div>
+                            <div class="icon"><studip-icon shape="courseware" :size="32" /></div>
                             <div class="text">{{ unit.element.attributes.title }}</div>
-                            <studip-icon shape="radiobutton-unchecked" size="24" class="unchecked" />
-                            <studip-icon shape="check-circle" size="24" class="check" />
+                            <studip-icon shape="radiobutton-unchecked" :size="24" class="unchecked" />
+                            <studip-icon shape="check-circle" :size="24" class="check" />
                         </label>
                     </template>
                 </fieldset>
@@ -237,12 +242,15 @@
 </template>
 
 <script>
-import CoursewareCompanionBox from './layouts/CoursewareCompanionBox.vue';
-import CoursewareStructuralElementSelector from './structural-element/CoursewareStructuralElementSelector.vue';
-import StudipWizardDialog from '../StudipWizardDialog.vue';
+import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareStructuralElementSelector from '../structural-element/CoursewareStructuralElementSelector.vue';
+import StudipWizardDialog from '../../StudipWizardDialog.vue';
 
 import { mapActions, mapGetters } from 'vuex';
 
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
 export default {
     name: 'courseware-tasks-dialog-distribute',
     components: {
@@ -316,7 +324,8 @@ export default {
             ],
             selectedSourceUnit: null,
             taskTitle: '',
-            submissionDate: '',
+            startDate: dateString(new Date()),
+            endDate: '',
             solverMayAddBlocks: true,
             selectedTask: null,
             selectedTargetUnit: null,
@@ -488,7 +497,7 @@ export default {
     },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
             loadCourseUnits: 'loadCourseUnits',
             loadUserUnits: 'loadUserUnits',
             loadStructuralElement: 'courseware-structural-elements/loadById',
@@ -522,10 +531,21 @@ export default {
                 return;
             }
             this.distributing = true;
+            const startDate = new Date(this.startDate);
+            startDate.setHours(0);
+            startDate.setMinutes(0);
+            startDate.setSeconds(0);
+            startDate.setMilliseconds(0);
+            const endDate = new Date(this.endDate);
+            endDate.setHours(23);
+            endDate.setMinutes(59);
+            endDate.setSeconds(59);
+            endDate.setMilliseconds(999);
             const taskGroup = {
                 attributes: {
                     title: this.taskTitle,
-                    'submission-date': new Date(this.submissionDate).toISOString(),
+                    'start-date': startDate.toISOString(),
+                    'end-date': endDate.toISOString(),
                     'solver-may-add-blocks': this.solverMayAddBlocks,
                 },
                 relationships: {
@@ -560,7 +580,7 @@ export default {
             this.$emit('newtask');
             this.distributing = false;
             this.setShowTasksDistributeDialog(false);
-            
+
         },
         validateSolvers() {
             if (
@@ -575,7 +595,7 @@ export default {
             return this.wizardSlots[5].valid;
         },
         validateTaskSettings() {
-            if (this.taskTitle !== '' && this.submissionDate !== '') {
+            if (this.taskTitle !== '' && this.endDate !== '') {
                 this.wizardSlots[2].valid = true;
             } else {
                 this.wizardSlots[2].valid = false;
@@ -651,7 +671,14 @@ export default {
         taskTitle() {
             this.validate();
         },
-        submissionDate() {
+        startDate() {
+            if (new Date(this.startDate) > new Date(this.endDate)) {
+                const endDate = new Date(this.startDate);
+                endDate.setDate(endDate.getDate() + 1);
+                this.endDate = dateString(endDate);
+            }
+        },
+        endDate() {
             this.validate();
         },
         selectedAutors() {
diff --git a/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
new file mode 100644
index 00000000000..a07356dfc68
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/EditFeedbackDialog.vue
@@ -0,0 +1,60 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Feedback zur Aufgabe ändern')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="420"
+        @close="$emit('close')"
+        @confirm="update"
+    >
+        <template #dialogContent>
+            <CompanionBox
+                v-if="localContent === ''"
+                mood="pointing"
+                :msgCompanion="
+                    $gettext('Sie haben kein Feedback geschrieben, beim Speichern wird dieses Feedback gelöscht!')
+                "
+            />
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Feedback') }}
+                    <textarea v-model="localContent" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+
+export default {
+    props: ['content'],
+    components: {
+        CompanionBox,
+    },
+    data: () => ({
+        localContent: '',
+    }),
+    methods: {
+        resetLocalVars() {
+            this.localContent = this.content;
+        },
+        update() {
+            this.$emit('update', { content: this.localContent });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        content(newValue) {
+            if (newValue !== this.localContent) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/TasksApp.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
similarity index 70%
rename from resources/vue/components/courseware/TasksApp.vue
rename to resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
index 8a406d80864..5701580d489 100644
--- a/resources/vue/components/courseware/TasksApp.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsIndex.vue
@@ -1,21 +1,21 @@
 <template>
     <div class="cw-tasks-wrapper">
         <div class="cw-tasks-list">
-            <courseware-dashboard-students v-if="userIsTeacher" />
-            <courseware-dashboard-tasks v-else />
+            <CoursewareDashboardStudents v-if="userIsTeacher" />
+            <CoursewareDashboardTasks v-else />
         </div>
         <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
-            <courseware-tasks-action-widget />
+            <CoursewareTasksActionWidget />
         </MountingPortal>
         <courseware-companion-overlay />
     </div>
 </template>
 
 <script>
-import CoursewareTasksActionWidget from './widgets/CoursewareTasksActionWidget.vue';
+import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
 import CoursewareDashboardTasks from './CoursewareDashboardTasks.vue';
 import CoursewareDashboardStudents from './CoursewareDashboardStudents.vue';
-import CoursewareCompanionOverlay from './layouts/CoursewareCompanionOverlay.vue';
+import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue';
 import { mapGetters } from 'vuex';
 
 export default {
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
new file mode 100644
index 00000000000..e17d18e60a4
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -0,0 +1,224 @@
+<template>
+    <div class="cw-tasks-wrapper">
+        <MountingPortal mountTo="#courseware-action-widget" name="sidebar-actions" v-if="userIsTeacher">
+            <CoursewareTasksActionWidget :taskGroup="taskGroup" />
+        </MountingPortal>
+
+        <div v-if="taskGroup" class="cw-tasks-list">
+            <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
+                <template #buttons>
+                    <router-link :to="{ name: 'task-groups-index' }">
+                        <StudipIcon shape="category-task" :size="24" />
+                    </router-link>
+                </template>
+                <template #breadcrumbList>
+                    <li>
+                        <router-link :to="{ name: 'task-groups-index' }">
+                            {{ $gettext('Aufgaben') }}
+                        </router-link>
+                    </li>
+                    <li>{{ taskGroup.attributes['title'] }}</li>
+                </template>
+            </CoursewareRibbon>
+
+            <TaskGroup
+                :taskGroup="taskGroup"
+                :tasks="tasksByGroup[taskGroup.id]"
+                @add-feedback="onShowAddFeedback"
+                @edit-feedback="onShowEditFeedback"
+                @solve-renewal="onShowSolveRenewal"
+            />
+        </div>
+        <CompanionBox
+            v-else-if="!tasksLoading"
+            :msgCompanion="$gettext('Diese Courseware-Aufgabe konnte nicht gefunden werden.')"
+        />
+
+        <AddFeedbackDialog
+            v-if="showAddFeedbackDialog"
+            :content="currentDialogFeedback.attributes.content"
+            @create="createFeedback"
+            @close="closeDialogs"
+        />
+
+        <EditFeedbackDialog
+            v-if="showEditFeedbackDialog"
+            :content="currentDialogFeedback.attributes.content"
+            @update="updateFeedback"
+            @close="closeDialogs"
+        />
+
+        <RenewalDialog
+            v-if="renewalTask"
+            :renewalDate="renewalDate"
+            :renewalState="renewalTask.attributes.renewal"
+            @update="updateRenewal"
+            @close="closeDialogs"
+        />
+
+        <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" />
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import AddFeedbackDialog from './AddFeedbackDialog.vue';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
+import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
+import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
+import EditFeedbackDialog from './EditFeedbackDialog.vue';
+import RenewalDialog from './RenewalDialog.vue';
+import TaskGroup from './TaskGroup.vue';
+import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
+import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
+import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
+
+export default {
+    components: {
+        AddFeedbackDialog,
+        CompanionBox,
+        CoursewareRibbon,
+        CoursewareTasksActionWidget,
+        CoursewareTasksDialogDistribute,
+        EditFeedbackDialog,
+        RenewalDialog,
+        TaskGroup,
+        TaskGroupsAddSolversDialog,
+        TaskGroupsDeleteDialog,
+        TaskGroupsModifyDeadlineDialog,
+    },
+    props: ['id'],
+    data() {
+        return {
+            currentDialogFeedback: {},
+            renewalTask: null,
+            showAddFeedbackDialog: false,
+            showEditFeedbackDialog: false,
+        };
+    },
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            getTaskGroup: 'courseware-task-groups/byId',
+            showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog',
+            showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog',
+            showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog',
+            showTasksDistributeDialog: 'tasks/showTasksDistributeDialog',
+            tasksByCid: 'tasks/tasksByCid',
+            tasksLoading: 'courseware-tasks/isLoading',
+            userIsTeacher: 'userIsTeacher',
+        }),
+        renewalDate() {
+            return this.renewalTask ? new Date(this.renewalTask.attributes['renewal-date']) : new Date();
+        },
+        taskGroup() {
+            return this.getTaskGroup({ id: this.id });
+        },
+        tasksByGroup() {
+            return this.tasksByCid(this.context.id).reduce((memo, task) => {
+                const key = task.relationships['task-group'].data.id;
+                (memo[key] || (memo[key] = [])).push(task);
+
+                return memo;
+            }, {});
+        },
+    },
+    methods: {
+        ...mapActions({
+            companionError: 'companionError',
+            companionSuccess: 'companionSuccess',
+            createTaskFeedback: 'createTaskFeedback',
+            deleteTaskFeedback: 'deleteTaskFeedback',
+            loadAllTasks: 'courseware-tasks/loadAll',
+            loadTaskGroup: 'tasks/loadTaskGroup',
+            updateTask: 'updateTask',
+            updateTaskFeedback: 'updateTaskFeedback',
+        }),
+        closeDialogs() {
+            this.showAddFeedbackDialog = false;
+            this.showEditFeedbackDialog = false;
+
+            this.currentDialogFeedback = {};
+            this.renewalTask = null;
+        },
+        createFeedback({ content }) {
+            if (content === '') {
+                this.companionError({
+                    info: this.$gettext('Bitte schreiben Sie ein Feedback.'),
+                });
+                return false;
+            }
+            this.currentDialogFeedback.attributes.content = content;
+            this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback });
+            this.closeDialogs();
+        },
+        onShowAddFeedback(task) {
+            this.currentDialogFeedback = {
+                attributes: { content: '' },
+                relationships: {
+                    task: {
+                        data: {
+                            id: task.id,
+                            type: task.type,
+                        },
+                    },
+                },
+            };
+            this.showAddFeedbackDialog = true;
+        },
+        onShowEditFeedback(feedback) {
+            this.currentDialogFeedback = _.cloneDeep(feedback);
+            this.showEditFeedbackDialog = true;
+        },
+        onShowSolveRenewal(task) {
+            this.renewalTask = _.cloneDeep(task);
+            this.renewalTask.attributes['renewal-date'] = new Date().toISOString();
+        },
+        reloadTasks() {
+            this.loadAllTasks({
+                options: {
+                    'filter[cid]': this.context.id,
+                    include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+                },
+            });
+        },
+        updateRenewal({ state, date }) {
+            const attributes = { renewal: state };
+            if (date) {
+                attributes['renewal-date'] = date.toISOString();
+            }
+
+            this.updateTask({ attributes, taskId: this.renewalTask.id });
+            this.closeDialogs();
+        },
+        async updateFeedback({ content }) {
+            if (content === '') {
+                await this.deleteTaskFeedback({ taskFeedbackId: this.currentDialogFeedback.id });
+                this.companionSuccess({ info: this.$gettext('Feedback wurde gelöscht.') });
+            } else {
+                await this.updateTaskFeedback({
+                    attributes: { content },
+                    taskFeedbackId: this.currentDialogFeedback.id,
+                });
+                this.companionSuccess({
+                    info: this.$gettext('Feedback wurde gespeichert.'),
+                });
+            }
+            this.closeDialogs();
+        },
+    },
+};
+</script>
+
+<style scoped>
+.cw-tasks-wrapper >>> .cw-ribbon-nav {
+    min-width: 24px;
+    padding: 0 1em;
+    height: 24px;
+    margin-top: 2px;
+}
+</style>
diff --git a/resources/vue/components/courseware/tasks/RenewalDialog.vue b/resources/vue/components/courseware/tasks/RenewalDialog.vue
new file mode 100644
index 00000000000..f08719e22d2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/RenewalDialog.vue
@@ -0,0 +1,79 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Verlängerungsanfrage bearbeiten')"
+        :confirmText="$gettext('Speichern')"
+        confirmClass="accept"
+        :closeText="$gettext('Schließen')"
+        closeClass="cancel"
+        height="350"
+        @close="$emit('close')"
+        @confirm="updateRenewal"
+    >
+        <template #dialogContent>
+            <form class="default" @submit.prevent="">
+                <label>
+                    {{ $gettext('Fristverlängerung') }}
+                    <select v-model="state">
+                        <option value="declined">
+                            {{ $gettext('ablehnen') }}
+                        </option>
+                        <option value="granted">
+                            {{ $gettext('gewähren') }}
+                        </option>
+                    </select>
+                </label>
+                <label v-if="state === 'granted'">
+                    {{ $gettext('neue Frist') }}
+                    <DateInput v-model="date" class="size-l" />
+                </label>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import DateInput from '../layouts/CoursewareDateInput.vue';
+export default {
+    props: ['renewalDate', 'renewalState'],
+    components: {
+        DateInput,
+    },
+    data: () => ({
+        date: null,
+        state: null,
+    }),
+    methods: {
+        resetLocalVars() {
+            this.date = this.renewalDate ?? null;
+            this.state = this.renewalState;
+        },
+        updateRenewal() {
+            const date = new Date(this.date);
+            date.setHours(23);
+            date.setMinutes(59);
+            date.setSeconds(59);
+            date.setMilliseconds(999);
+
+            this.$emit('update', {
+                state: this.state,
+                date: this.state === 'granted' ? date || Date.now() : null,
+            });
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        renewalDate(newValue) {
+            if (newValue !== this.date) {
+                this.resetLocalVars();
+            }
+        },
+        renewalState(newValue) {
+            if (newValue !== this.state) {
+                this.resetLocalVars();
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue
new file mode 100644
index 00000000000..62449f1c035
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroup.vue
@@ -0,0 +1,84 @@
+<template>
+    <div>
+        <CompanionBox :msgCompanion="statusMessage">
+            <template #companionActions>
+                <span>
+                    {{ $gettext('Bearbeitungszeit') }}
+                    <StudipDate :date="startDate" /> - <StudipDate :date="endDate" />
+                </span>
+            </template>
+        </CompanionBox>
+
+        <section v-if="tasks.length > 0">
+            <table class="default">
+                <caption>
+                    {{ $gettext('Verteilte Aufgaben') }}
+                </caption>
+                <thead>
+                    <tr>
+                        <th>{{ $gettext('Status') }}</th>
+                        <th>{{ $gettext('Teilnehmende/Gruppen') }}</th>
+                        <th class="responsive-hidden">{{ $gettext('Seite') }}</th>
+                        <th>{{ $gettext('bearbeitet') }}</th>
+                        <th>{{ $gettext('Abgabefrist') }}</th>
+                        <th>{{ $gettext('Abgabe') }}</th>
+                        <th class="responsive-hidden renewal">{{ $gettext('Verlängerungsanfrage') }}</th>
+                        <th class="responsive-hidden feedback">{{ $gettext('Feedback') }}</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <TaskItem
+                        v-for="task in tasks"
+                        :task="task"
+                        :taskGroup="taskGroup"
+                        :key="task.id"
+                        @add-feedback="(task) => $emit('add-feedback', task)"
+                        @edit-feedback="(feedback) => $emit('edit-feedback', feedback)"
+                        @solve-renewal="(task) => $emit('solve-renewal', task)"
+                    />
+                </tbody>
+            </table>
+        </section>
+        <div v-else>
+            <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" />
+        </div>
+    </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
+import StudipDate from '../../StudipDate.vue';
+import TaskItem from './TaskGroupTaskItem.vue';
+import { getStatus } from './task-groups-helper.js';
+
+export default {
+    components: { CompanionBox, StudipDate, TaskItem },
+    props: ['taskGroup', 'tasks'],
+    computed: {
+        ...mapGetters({
+            coursewareContext: 'context',
+        }),
+        actionMenuContext() {
+            return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), {
+                taskGroup: this.taskGroup.attributes.title,
+            });
+        },
+        endDate() {
+            return new Date(this.taskGroup.attributes['end-date']);
+        },
+        isAfter() {
+            return new Date() > this.endDate;
+        },
+        startDate() {
+            return new Date(this.taskGroup.attributes['start-date']);
+        },
+        status() {
+            return getStatus(this.taskGroup);
+        },
+        statusMessage() {
+            return this.status.description;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
new file mode 100644
index 00000000000..b684f105b65
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupTaskItem.vue
@@ -0,0 +1,118 @@
+<template>
+    <tr>
+        <td>
+            <studip-icon
+                v-if="status.shape !== undefined"
+                :shape="status.shape"
+                :role="status.role"
+                :title="status.description"
+                aria-hidden="true"
+            />
+            <span class="sr-only">{{ status.description }}</span>
+        </td>
+        <td>
+            <span v-if="user">
+                <studip-icon shape="person2" role="info" aria-hidden="true" :title="$gettext('Teilnehmende Person')" />
+                <span class="sr-only">{{ $gettext('Teilnehmende Person') }}</span>
+                {{ user.attributes['formatted-name'] }}
+            </span>
+            <span v-if="group">
+                <studip-icon shape="group2" role="info" aria-hidden="true" :title="$gettext('Gruppe')" />
+                <span class="sr-only">{{ $gettext('Gruppe') }}</span>
+                {{ group.attributes['name'] }}
+            </span>
+        </td>
+        <td class="responsive-hidden">
+            <a v-if="task.attributes.submitted" :href="getLinkToElement(element)">
+                {{ element.attributes.title }}
+            </a>
+            <span v-else>{{ element.attributes.title }}</span>
+        </td>
+        <td>{{ task.attributes?.progress?.toFixed(2) || '-.--' }}%</td>
+        <td>{{ getReadableDate(task.attributes['submission-date']) }}</td>
+        <td>
+            <studip-icon v-if="task.attributes.submitted" shape="accept" role="status-green" />
+        </td>
+        <td class="responsive-hidden">
+            <button v-show="task.attributes.renewal === 'pending'" class="button" @click="$emit('solve-renewal', task)">
+                {{ $gettext('Anfrage bearbeiten') }}
+            </button>
+            <span v-show="task.attributes.renewal === 'declined'">
+                <studip-icon shape="decline" role="status-red" />
+                {{ $gettext('Anfrage abgelehnt') }}
+            </span>
+            <span v-show="task.attributes.renewal === 'granted'">
+                {{ $gettext('verlängert bis') }}:
+                {{ getReadableDate(task.attributes['renewal-date']) }}
+            </span>
+            <studip-icon
+                v-if="task.attributes.renewal === 'declined' || task.attributes.renewal === 'granted'"
+                :title="$gettext('Anfrage bearbeiten')"
+                class="edit"
+                shape="edit"
+                @click="$emit('solve-renewal', task)"
+            />
+        </td>
+        <td class="responsive-hidden">
+            <span
+                v-if="feedback"
+                :title="
+                    $gettextInterpolate($gettext('Feedback geschrieben am: %{ date }'), {
+                        date: getReadableDate(feedback.attributes['chdate']),
+                    })
+                "
+            >
+                <studip-icon shape="accept" role="status-green" />
+                {{ $gettext('Feedback gegeben') }}
+                <studip-icon
+                    :title="$gettext('Feedback bearbeiten')"
+                    class="edit"
+                    shape="edit"
+                    @click="$emit('edit-feedback', feedback)"
+                />
+            </span>
+
+            <button v-show="!feedback && task.attributes.submitted" class="button" @click="$emit('add-feedback', task)">
+                {{ $gettext('Feedback geben') }}
+            </button>
+        </td>
+    </tr>
+</template>
+<script>
+import taskHelper from '../../../mixins/courseware/task-helper.js';
+import { mapGetters } from 'vuex';
+
+export default {
+    mixins: [taskHelper],
+    props: ['task', 'taskGroup'],
+    computed: {
+        ...mapGetters({
+            elementById: 'courseware-structural-elements/byId',
+            feedbackById: 'courseware-task-feedback/byId',
+            statusGroupById: 'status-groups/byId',
+            userById: 'users/byId',
+        }),
+        element() {
+            return this.elementById({ id: this.task.relationships['structural-element'].data.id });
+        },
+        feedback() {
+            const id = this.task.relationships['task-feedback'].data?.id;
+            return id ? this.feedbackById({ id }) : null;
+        },
+        group() {
+            const { id, type } = this.solver;
+            return type === 'status-groups' ? this.statusGroupById({ id }) : null;
+        },
+        solver() {
+            return this.task.relationships.solver.data;
+        },
+        status() {
+            return this.getStatus(this.task);
+        },
+        user() {
+            const { id, type } = this.solver;
+            return type === 'users' ? this.userById({ id }) : null;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue
new file mode 100644
index 00000000000..a84334169a2
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue
@@ -0,0 +1,224 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Teilnehmende hinzufügen')"
+        :confirmText="$gettext('Hinzufügen')"
+        confirmClass="accept"
+        :confirmDisabled="!taskSolverType"
+        :closeText="$gettext('Abbrechen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+        width="700"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <label>
+                    {{ $gettext('Verteilen an') }}
+                    <select v-model="taskSolverType">
+                        <option value="users">{{ $gettext('Studierende') }}</option>
+                        <option value="status-groups">{{ $gettext('Gruppen') }}</option>
+                    </select>
+                </label>
+
+                <template v-if="taskSolverType === 'users'">
+                    <CoursewareCompanion
+                        v-show="autor_members.length === 0"
+                        :msgCompanion="$gettext('Es wurden keine Studierenden in dieser Veranstaltung gefunden.')"
+                        mood="pointing"
+                    />
+                    <table v-show="autor_members.length > 0" class="default">
+                        <thead>
+                            <tr>
+                                <th></th>
+                                <th>{{ $gettext('Name') }}</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <tr v-for="user in autor_members" :key="user.user_id">
+                                <td>
+                                    <input
+                                        type="checkbox"
+                                        v-model="selectedAutors"
+                                        :disabled="isSolver(user.user_id)"
+                                        :value="user.user_id"
+                                        :aria-label="
+                                            $gettextInterpolate($gettext('%{userName} auswählen'), {
+                                                userName: user.formattedname,
+                                            })
+                                        "
+                                    />
+                                </td>
+                                <td>{{ user.formattedname }}</td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </template>
+                <template v-if="taskSolverType === 'status-groups'">
+                    <CoursewareCompanion
+                        v-show="groups.length === 0"
+                        :msgCompanion="$gettext('Es wurden keine Gruppen in dieser Veranstaltung gefunden.')"
+                        mood="pointing"
+                    />
+                    <table v-show="groups.length > 0" class="default">
+                        <thead>
+                            <tr>
+                                <th></th>
+                                <th>{{ $gettext('Gruppenname') }}</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <tr v-for="group in groups" :key="group.id">
+                                <td>
+                                    <input
+                                        type="checkbox"
+                                        v-model="selectedGroups"
+                                        :disabled="isSolver(group.id)"
+                                        :value="group.id"
+                                        :aria-label="
+                                            $gettextInterpolate($gettext('%{groupName} auswählen'), {
+                                                groupName: group.name,
+                                            })
+                                        "
+                                    />
+                                </td>
+                                <td>{{ group.name }}</td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </template>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import CoursewareCompanion from '../layouts/CoursewareCompanionBox.vue';
+
+export default {
+    props: ['taskGroup'],
+    components: {
+        CoursewareCompanion,
+    },
+    data: () => ({
+        selectedAutors: [],
+        selectedGroups: [],
+        storing: false,
+        taskSolverType: null,
+    }),
+    computed: {
+        ...mapGetters({
+            context: 'context',
+            relatedCourseMemberships: 'course-memberships/related',
+            relatedCourseStatusGroups: 'status-groups/related',
+            relatedUser: 'users/related',
+            tasksByCid: 'tasks/tasksByCid',
+        }),
+        autor_members() {
+            return Object.keys(this.users).length === 0 && this.users.constructor === Object
+                ? []
+                : this.users.filter(({ perm }) => perm === 'autor').map((obj) => ({ ...obj, active: false }));
+        },
+        groups() {
+            return (
+                this.relatedCourseStatusGroups({
+                    parent: { type: 'courses', id: this.context.id },
+                    relationship: 'status-groups',
+                })?.map(({ id, attributes: { name } }) => ({ id, name })) ?? []
+            );
+        },
+        solversById() {
+            return new Map(this.solvers.map(({ id, type }) => [id, { id, type }]));
+        },
+        solvers() {
+            return this.tasks.map((task) => task.relationships.solver.data);
+        },
+        tasks() {
+            return this.tasksByCid(this.context.id).filter(
+                (task) => task.relationships['task-group'].data.id === this.taskGroup.id
+            );
+        },
+        users() {
+            const memberships = this.relatedCourseMemberships({
+                parent: { type: 'courses', id: this.context.id },
+                relationship: 'memberships',
+            });
+
+            return (
+                memberships?.map(({ type, id, attributes: { permission } }) => {
+                    const member = this.relatedUser({ parent: { type, id }, relationship: 'user' });
+
+                    return {
+                        user_id: member.id,
+                        formattedname: member.attributes['formatted-name'],
+                        username: member.attributes['username'],
+                        perm: permission,
+                    };
+                }) ?? []
+            );
+        },
+    },
+    methods: {
+        ...mapActions({
+            addSolversToTaskGroup: 'tasks/addSolversToTaskGroup',
+            loadCourseMemberships: 'course-memberships/loadRelated',
+            loadCourseStatusGroups: 'status-groups/loadRelated',
+            setShowDialog: 'tasks/setShowTaskGroupsAddSolversDialog',
+        }),
+        isSolver(id) {
+            return !!this.solvers.find((solver) => solver.id === id);
+        },
+        onClose() {
+            this.setShowDialog(false);
+        },
+        onConfirm() {
+            if (!this.taskSolverType || this.storing) {
+                return;
+            }
+            this.storing = true;
+
+            const solvers = this[this.taskSolverType === 'users' ? 'selectedAutors' : 'selectedGroups'];
+            const ids = solvers.filter((id) => !this.solversById.has(id));
+            this.addSolversToTaskGroup({
+                taskGroup: this.taskGroup,
+                solvers: ids.map((id) => ({ id, type: this.taskSolverType })),
+            })
+                .then(() => {
+                    this.$emit('newtask');
+                    this.onClose();
+                })
+                .finally(() => (this.storing = false));
+        },
+        resetLocalVars() {
+            this.selectedAutors = this.solvers.filter(({ type }) => type === 'users').map(({ id }) => id);
+            this.selectedGroups = this.solvers.filter(({ type }) => type === 'status-groups').map(({ id }) => id);
+            this.taskSolverType = this.selectedAutors.length
+                ? 'users'
+                : this.selectedGroups.length
+                ? 'status-groups'
+                : null;
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+
+        const parent = { type: 'courses', id: this.context.id };
+        this.loadCourseMemberships({
+            parent,
+            relationship: 'memberships',
+            options: {
+                include: 'user',
+                'page[offset]': 0,
+                'page[limit]': 10000,
+                'filter[permission]': 'autor',
+            },
+        });
+        this.loadCourseStatusGroups({ parent, relationship: 'status-groups' });
+    },
+    watch: {
+        taskGroup() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
new file mode 100644
index 00000000000..b1a151d8d96
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupsDeleteDialog.vue
@@ -0,0 +1,33 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Aufgabe löschen')"
+        :question="$gettext('Möchten Sie die Aufgabe wirklich löschen?')"
+        height="200"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions } from 'vuex';
+
+export default {
+    props: ['taskGroup'],
+    methods: {
+        ...mapActions({
+            deleteTaskGroup: 'courseware-task-groups/delete',
+            setShowTaskGroupsDeleteDialog: 'tasks/setShowTaskGroupsDeleteDialog'
+        }),
+        onClose() {
+            this.setShowTaskGroupsDeleteDialog(false);
+        },
+        onConfirm() {
+            this.deleteTaskGroup(this.taskGroup).then(() => {
+                this.onClose();
+                this.$router.push({ name: 'task-groups-index' });
+            });
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
new file mode 100644
index 00000000000..39198af5965
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/TaskGroupsModifyDeadlineDialog.vue
@@ -0,0 +1,117 @@
+<template>
+    <studip-dialog
+        :title="$gettext('Bearbeitungszeit verlängern')"
+        :confirmText="$gettext('Verlängern')"
+        confirmClass="accept"
+        :closeText="$gettext('Abbrechen')"
+        closeClass="cancel"
+        @close="onClose"
+        @confirm="onConfirm"
+    >
+        <template #dialogContent>
+            <form class="default">
+                <p>
+                    {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" /> - <StudipDate
+                        :date="endDate"
+                    />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }})
+                </p>
+                <div class="formpart">
+                    <label class="studiprequired">
+                        <span class="textlabel">{{ $gettext('Bearbeitungszeit verlängern bis zum') }}</span>
+                        <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                        <input
+                            :id="`task-groups-${uid}`"
+                            name="end-date"
+                            type="date"
+                            v-model="localEndDate"
+                            :min="endDateString"
+                            class="size-l"
+                            required
+                        />
+                    </label>
+                </div>
+                <p>
+                    {{ $gettext('Verlängerte Bearbeitungszeit:') }} <StudipDate :date="startDate" /> - <StudipDate
+                        :date="newEndDate"
+                    />
+                    ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})
+                </p>
+            </form>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import StudipDate from '../../StudipDate.vue';
+
+const midnight = (_date) => {
+    const date = new Date(_date);
+    date.setHours(0);
+    date.setMinutes(0);
+    date.setSeconds(0);
+    date.setMilliseconds(0);
+    return date;
+};
+
+const dateString = (date) =>
+    `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`;
+
+let nextUid = 0;
+
+export default {
+    props: ['taskGroup'],
+    components: {
+        StudipDate,
+    },
+    data: () => ({ localEndDate: null, uid: nextUid++ }),
+    computed: {
+        endDate() {
+            return midnight(this.taskGroup?.attributes?.['end-date'] ?? new Date());
+        },
+        endDateString() {
+            return dateString(this.endDate);
+        },
+        newDuration() {
+            return this.localEndDate
+                ? Math.floor((midnight(this.localEndDate) - this.startDate) / (1000 * 60 * 60 * 24))
+                : 0;
+        },
+        newEndDate() {
+            return this.localEndDate ? midnight(this.localEndDate) : this.endDate;
+        },
+        oldDuration() {
+            return Math.floor((this.endDate - this.startDate) / (1000 * 60 * 60 * 24));
+        },
+        startDate() {
+            return midnight(this.taskGroup.attributes['start-date']);
+        },
+    },
+    methods: {
+        ...mapActions({
+            modifyDeadline: 'tasks/modifyDeadlineOfTaskGroup',
+            setShowDialog: 'tasks/setShowTaskGroupsModifyDeadlineDialog',
+        }),
+        onClose() {
+            this.setShowDialog(false);
+        },
+        onConfirm() {
+            const endDate = midnight(this.localEndDate);
+            this.modifyDeadline({ taskGroup: this.taskGroup, endDate });
+            this.onClose();
+        },
+        resetLocalVars() {
+            this.localEndDate = dateString(this.endDate ?? new Date());
+        },
+    },
+    mounted() {
+        this.resetLocalVars();
+    },
+    watch: {
+        taskGroup() {
+            this.resetLocalVars();
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/tasks/task-groups-helper.js b/resources/vue/components/courseware/tasks/task-groups-helper.js
new file mode 100644
index 00000000000..8a9e4697229
--- /dev/null
+++ b/resources/vue/components/courseware/tasks/task-groups-helper.js
@@ -0,0 +1,31 @@
+import { $gettext } from '../../../../assets/javascripts/lib/gettext';
+
+export function getStatus(taskGroup) {
+    const now = new Date();
+    const startDate = new Date(taskGroup.attributes['start-date']);
+    const endDate = new Date(taskGroup.attributes['end-date']);
+
+    if (startDate <= now && now <= endDate) {
+        return {
+            shape: 'span-3quarter',
+            role: 'status-green',
+            description: $gettext('Die Bearbeitungszeit hat begonnen.'),
+        };
+    }
+
+    if (now < startDate) {
+        return {
+            shape: 'span-empty',
+            role: 'status-yellow',
+            description: $gettext('Die Bearbeitungszeit hat noch nicht begonnen.'),
+        };
+    }
+
+    if (endDate < now) {
+        return {
+            shape: 'span-full',
+            role: 'status-red',
+            description: $gettext('Die Bearbeitungszeit ist beendet.'),
+        };
+    }
+}
diff --git a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
index c2f54e9171d..cf37c6f6861 100644
--- a/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
+++ b/resources/vue/components/courseware/widgets/CoursewareTasksActionWidget.vue
@@ -2,7 +2,24 @@
     <sidebar-widget id="courseware-action-widget" :title="$gettext('Aktionen')">
         <template #content>
             <ul class="widget-list widget-links cw-action-widget">
-                <li class="cw-action-widget-add">
+                <template v-if="taskGroup">
+                    <li v-if="isBeforeEndDate" class="cw-action-widget-task-groups-deadline">
+                        <button @click="modifyDeadline(taskGroup)">
+                            {{ $gettext('Bearbeitungszeit verlängern') }}
+                        </button>
+                    </li>
+                    <li v-if="isBeforeEndDate" class="cw-action-widget-task-groups-add-solvers">
+                        <button @click="addSolvers(taskGroup)">
+                            {{ $gettext('Teilnehmende hinzufügen') }}
+                        </button>
+                    </li>
+                    <li class="cw-action-widget-task-groups-delete">
+                        <button @click="deleteTaskGroup(taskGroup)">
+                            {{ $gettext('Aufgabe löschen') }}
+                        </button>
+                    </li>
+                </template>
+                <li v-else class="cw-action-widget-add">
                     <button @click="setShowTasksDistributeDialog(true)">
                         {{ $gettext('Aufgabe verteilen') }}
                     </button>
@@ -22,10 +39,34 @@ export default {
     components: {
         SidebarWidget,
     },
+    props: ['taskGroup'],
+    computed: {
+        isBeforeEndDate() {
+            return this.taskGroup && new Date() < new Date(this.taskGroup.attributes['end-date']);
+        },
+    },
     methods: {
         ...mapActions({
-            setShowTasksDistributeDialog: 'setShowTasksDistributeDialog',
+            addSolvers: 'tasks/setShowTaskGroupsAddSolversDialog',
+            deleteTaskGroup: 'tasks/setShowTaskGroupsDeleteDialog',
+            modifyDeadline: 'tasks/setShowTaskGroupsModifyDeadlineDialog',
+            setShowTasksDistributeDialog: 'tasks/setShowTasksDistributeDialog',
         }),
-    }
+    },
+};
+</script>
+
+<style scoped>
+.cw-action-widget-task-groups-add-solvers {
+    background-image: url('../images/icons/blue/add.svg');
+    background-size: 16px;
+}
+.cw-action-widget-task-groups-deadline {
+    background-image: url('../images/icons/blue/date.svg');
+    background-size: 16px;
+}
+.cw-action-widget-task-groups-delete {
+    background-image: url('../images/icons/blue/trash.svg');
+    background-size: 16px;
 }
-</script>
\ No newline at end of file
+</style>
diff --git a/resources/vue/components/stock-images/colors.js b/resources/vue/components/stock-images/colors.js
index 4ba138cdea0..910b1435d30 100644
--- a/resources/vue/components/stock-images/colors.js
+++ b/resources/vue/components/stock-images/colors.js
@@ -1,4 +1,4 @@
-import { $gettext } from '@/assets/javascripts/lib/gettext.js';
+import { $gettext } from '@/assets/javascripts/lib/gettext';
 
 const colors = [
     { name: $gettext('Schwarz'), hex: '#000000' },
diff --git a/resources/vue/components/stock-images/filters.js b/resources/vue/components/stock-images/filters.js
index 55cf72697ba..42de27dc6fc 100644
--- a/resources/vue/components/stock-images/filters.js
+++ b/resources/vue/components/stock-images/filters.js
@@ -1,4 +1,4 @@
-import { $gettext } from '@/assets/javascripts/lib/gettext.js';
+import { $gettext } from '@/assets/javascripts/lib/gettext';
 import { fromHex, rgbToCIELab, cie94 } from 'colorpare';
 
 const SQUARE_DELTA = 1.1;
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index 191385f47c3..59c0ebca225 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -2,6 +2,7 @@ import CoursewareModule from './store/courseware/courseware.module';
 import CoursewareStructureModule from './store/courseware/structure.module';
 import FileChooserStore from './store/file-chooser.js';
 import CoursewareStructuralElement from './components/courseware/structural-element/CoursewareStructuralElement.vue';
+import CoursewareTasksModule from './store/courseware/courseware-tasks.module';
 import IndexApp from './components/courseware/IndexApp.vue';
 import PluginManager from './components/courseware/plugin-manager.js';
 import Vue from 'vue';
@@ -89,6 +90,7 @@ const mountApp = async (STUDIP, createApp, element) => {
             courseware: CoursewareModule,
             'courseware-structure': CoursewareStructureModule,
             'file-chooser': FileChooserStore,
+            'tasks': CoursewareTasksModule,
             ...mapResourceModules({
                 names: [
                     'courses',
diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js
index 2f332466d79..9c01b7190f2 100644
--- a/resources/vue/courseware-tasks-app.js
+++ b/resources/vue/courseware-tasks-app.js
@@ -1,5 +1,7 @@
-import TasksApp from './components/courseware/TasksApp.vue';
+import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue';
+import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import VueRouter, { RouterView } from 'vue-router';
 import Vuex from 'vuex';
 import CoursewareModule from './store/courseware/courseware.module';
 import CoursewareTasksModule from './store/courseware/courseware-tasks.module';
@@ -17,6 +19,40 @@ const mountApp = async (STUDIP, createApp, element) => {
 
     const httpClient = getHttpClient();
 
+    const routes = [
+        {
+            path: '/',
+            name: 'task-groups-index',
+            component: TaskGroupsIndex,
+        },
+        {
+            path: '/task-groups/:id',
+            name: 'task-groups-show',
+            component: TaskGroupsShow,
+            props: true,
+        },
+    ];
+
+    const base = new URL(
+        window.STUDIP.URLHelper.getURL(
+            'dispatch.php/course/courseware/tasks',
+            { cid: STUDIP.URLHelper.parameters.cid },
+            true
+        )
+    );
+    const router = new VueRouter({
+        base: base.pathname,
+        mode: 'history',
+        routes,
+    });
+    router.beforeEach((to, from, next) => {
+        if ('cid' in to?.query) {
+            next();
+        } else {
+            next({ ...to, query: { ...to.query, cid: window.STUDIP.URLHelper.parameters.cid } });
+        }
+    });
+
     const store = new Vuex.Store({
         modules: {
             courseware: CoursewareModule,
@@ -71,22 +107,18 @@ const mountApp = async (STUDIP, createApp, element) => {
     }
 
     store.dispatch('setUserId', STUDIP.USER_ID);
-    await store.dispatch('users/loadById', {id: STUDIP.USER_ID});
+    await store.dispatch('users/loadById', { id: STUDIP.USER_ID });
     store.dispatch('setHttpClient', httpClient);
     store.dispatch('coursewareContext', {
         id: entry_id,
         type: entry_type,
     });
     await store.dispatch('loadTeacherStatus', STUDIP.USER_ID);
-    store.dispatch('courseware-tasks/loadAll', {
-        options: {
-            'filter[cid]': entry_id,
-            include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
-        },
-    });
+    await store.dispatch('tasks/loadTasksOfCourse', { cid: entry_id });
 
     const app = createApp({
-        render: (h) => h(TasksApp),
+        render: (h) => h(RouterView),
+        router,
         store,
     });
 
diff --git a/resources/vue/mixins/courseware/task-helper.js b/resources/vue/mixins/courseware/task-helper.js
index 0bc694c1226..a0510f7697d 100644
--- a/resources/vue/mixins/courseware/task-helper.js
+++ b/resources/vue/mixins/courseware/task-helper.js
@@ -8,7 +8,7 @@ export default {
             limit.setDate(now.getDate() + 3);
             status.canSubmit = true;
 
-            if (now < submissionDate) {
+            if (now <= submissionDate) {
                 status.shape = 'span-empty';
                 status.role = 'status-green';
                 status.description = this.$gettext('Aufgabe bereit');
@@ -20,7 +20,7 @@ export default {
                     status.description = this.$gettext('Aufgabe muss bald abgegeben werden');
                 }
 
-                if (now >= submissionDate) {
+                if (now > submissionDate) {
                     status.canSubmit = false;
                     status.shape = 'span-full';
                     status.role = 'status-red';
@@ -34,7 +34,7 @@ export default {
                     status.description = this.$gettext('Aufgabe muss bald abgegeben werden');
                 }
 
-                if (now >= renewalDate) {
+                if (now > renewalDate) {
                     status.canSubmit = false;
                     status.shape = 'span-full';
                     status.role = 'status-red';
diff --git a/resources/vue/store/AdminCoursesStore.js b/resources/vue/store/AdminCoursesStore.js
index 5b20e7013cb..509239b91ba 100644
--- a/resources/vue/store/AdminCoursesStore.js
+++ b/resources/vue/store/AdminCoursesStore.js
@@ -1,5 +1,5 @@
 import Screenreader from '../../assets/javascripts/lib/screenreader.js';
-import { $gettext } from '../../assets/javascripts/lib/gettext.js';
+import { $gettext } from '../../assets/javascripts/lib/gettext';
 
 export default {
     namespaced: true,
diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js
index fd5152dfa83..06224524dad 100644
--- a/resources/vue/store/courseware/courseware-tasks.module.js
+++ b/resources/vue/store/courseware/courseware-tasks.module.js
@@ -1,5 +1,8 @@
 const getDefaultState = () => {
     return {
+        showTaskGroupsAddSolversDialog: false,
+        showTaskGroupsDeleteDialog: false,
+        showTaskGroupsModifyDeadlineDialog: false,
         showTasksDistributeDialog: false,
     };
 };
@@ -7,29 +10,99 @@ const getDefaultState = () => {
 const initialState = getDefaultState();
 
 const getters = {
+    showTaskGroupsAddSolversDialog(state) {
+        return state.showTaskGroupsAddSolversDialog;
+    },
+    showTaskGroupsDeleteDialog(state) {
+        return state.showTaskGroupsDeleteDialog;
+    },
+    showTaskGroupsModifyDeadlineDialog(state) {
+        return state.showTaskGroupsModifyDeadlineDialog;
+    },
     showTasksDistributeDialog(state) {
         return state.showTasksDistributeDialog;
     },
+    taskGroupsByCid(state, getters, rootState, rootGetters) {
+        return (cid) => {
+            return rootGetters['courseware-task-groups/all'].filter(
+                (taskGroup) => taskGroup.relationships.course.data.id === cid
+            );
+        };
+    },
+    tasksByCid(state, getters, rootState, rootGetters) {
+        return (cid) => {
+            const taskGroupIds = getters.taskGroupsByCid(cid).map(({ id }) => id);
+
+            return rootGetters['courseware-tasks/all'].filter((task) =>
+                taskGroupIds.includes(task.relationships['task-group'].data.id)
+            );
+        };
+    },
 };
 
 export const state = { ...initialState };
 
 export const actions = {
     // setters
+    setShowTaskGroupsAddSolversDialog({ commit }, context) {
+        commit('setShowTaskGroupsAddSolversDialog', context);
+    },
+    setShowTaskGroupsDeleteDialog({ commit }, context) {
+        commit('setShowTaskGroupsDeleteDialog', context);
+    },
+    setShowTaskGroupsModifyDeadlineDialog({ commit }, context) {
+        commit('setShowTaskGroupsModifyDeadlineDialog', context);
+    },
     setShowTasksDistributeDialog({ commit }, context) {
         commit('setShowTasksDistributeDialog', context);
     },
 
     // other actions
+    loadTasksOfCourse({ dispatch }, { cid }) {
+        const options = {
+            'filter[cid]': cid,
+            include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer',
+        };
+        return dispatch('courseware-tasks/loadAll', { options }, { root: true });
+    },
+
+    loadTaskGroup({ dispatch }, { id }) {
+        const options = {
+            include: 'lecturer',
+        };
+        return dispatch('courseware-task-groups/loadById', { id, options }, { root: true });
+    },
+
+    modifyDeadlineOfTaskGroup({ dispatch }, { taskGroup, endDate }) {
+        taskGroup.attributes['end-date'] = endDate.toISOString();
+
+        return dispatch('courseware-task-groups/update', taskGroup, { root: true });
+    },
+
+    addSolversToTaskGroup({ dispatch, rootGetters }, { taskGroup, solvers }) {
+        return rootGetters.httpClient.post(`courseware-task-groups/${+taskGroup.id}/relationships/solvers`, {
+            data: solvers,
+        });
+    },
 };
 
 export const mutations = {
-    setShowTasksDistributeDialog(state, data){
+    setShowTaskGroupsAddSolversDialog(state, data) {
+        state.showTaskGroupsAddSolversDialog = data;
+    },
+    setShowTasksDistributeDialog(state, data) {
         state.showTasksDistributeDialog = data;
     },
+    setShowTaskGroupsDeleteDialog(state, data) {
+        state.showTaskGroupsDeleteDialog = data;
+    },
+    setShowTaskGroupsModifyDeadlineDialog(state, data) {
+        state.showTaskGroupsModifyDeadlineDialog = data;
+    },
 };
 
 export default {
+    namespaced: true,
     state,
     actions,
     mutations,
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 8b2f8da0c45..056802f7b8e 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -575,7 +575,7 @@ export const actions = {
         element.attributes.commentable = true;
 
         const updatedElement =  await dispatch('setStructuralElementComments', { element: element });
-        
+
         return updatedElement;
 
     },
@@ -584,7 +584,7 @@ export const actions = {
         element.attributes.commentable = false;
 
         const updatedElement =  await dispatch('setStructuralElementComments', { element: element });
-        
+
         return updatedElement;
     },
 
@@ -678,7 +678,7 @@ export const actions = {
         block.attributes.commentable = true;
 
         const updatedBlock =  await dispatch('setBlockComments', { block: block });
-        
+
         return updatedBlock;
 
     },
@@ -687,7 +687,7 @@ export const actions = {
         block.attributes.commentable = false;
 
         const updatedBlock =  await dispatch('setBlockComments', { block: block });
-        
+
         return updatedBlock;
     },
 
diff --git a/tsconfig.json b/tsconfig.json
index 55b45dc2e0f..2ada63c6be2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,9 +1,11 @@
 {
     "compilerOptions": {
-        "target": "es2015",
+        "allowJs": true,
+        "module": "es2020",
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
         "strict": true,
-        "module": "es2015",
-        "moduleResolution": "node"
+        "target": "es2020"
     },
     "include": ["resources/**/*.ts", "resources/**/*.vue"],
     "exclude": ["node_modules"]
diff --git a/webpack.common.js b/webpack.common.js
index 3dd376d6f93..8edc9c6b150 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -172,6 +172,7 @@ module.exports = {
             'jquery-ui/widgets/resizable': 'jquery-ui/ui/widgets/resizable',
             '@': path.resolve(__dirname, 'resources')
         },
+        extensions: ['.ts', '.vue', '.js'],
         fallback: {
             'stream': require.resolve("stream-browserify"),
             'buffer': require.resolve("buffer/")
diff --git a/webpack.dev.js b/webpack.dev.js
index c0cee71c597..92fb8a8e147 100644
--- a/webpack.dev.js
+++ b/webpack.dev.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
 const { merge } = require('webpack-merge');
 const common = require('./webpack.common.js');
 const WebpackNotifierPlugin = require('webpack-notifier');
@@ -10,8 +11,13 @@ const statusesPaths = {
 
 module.exports = merge(common, {
     mode: 'development',
-    devtool: 'eval',
+    devtool: 'eval-cheap-module-source-map',
     plugins: [
+        new webpack.WatchIgnorePlugin({
+            paths:[
+                /\.d\.[cm]ts$/
+            ]
+        }),
         new WebpackNotifierPlugin({
             appID: 'Stud.IP Webpack',
             title: function (params) {
-- 
GitLab