From 4b946d95b8a1a84614e52168ea73f20dc0f57623 Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Mon, 25 Nov 2024 07:04:02 +0000
Subject: [PATCH] Resolve "Umbau der Verwaltung von Anmeldesets auf Vue.js"

Closes #3270

Merge request studip/studip!2413
---
 app/controllers/admission/courseset.php       |  59 ++
 .../admission/restricted_courses.php          |   2 +-
 .../admission/courseset/_institute_choose.php |  15 +-
 app/views/admission/courseset/configure.php   | 268 -------
 app/views/admission/rule/configure.php        |  15 -
 .../ConditionalAdmission.php                  | 120 ++-
 .../templates/configure.php                   |  83 +-
 .../conditionaladmission/templates/info.php   |  20 +-
 .../CourseMemberAdmission.php                 |  31 +-
 .../templates/configure.php                   | 128 +---
 .../limitedadmission/LimitedAdmission.php     |  19 +-
 .../limitedadmission/templates/configure.php  |  10 +-
 .../lockedadmission/LockedAdmission.php       |  19 +-
 .../lockedadmission/templates/configure.php   |   8 +-
 .../ParticipantRestrictedAdmission.php        |  33 +-
 .../templates/configure.php                   |  35 +-
 .../templates/info.php                        |   8 +-
 .../passwordadmission/PasswordAdmission.php   |  19 +-
 .../passwordadmission/templates/configure.php |  18 +-
 .../PreferentialAdmission.php                 |  59 +-
 .../templates/configure.php                   |  39 +-
 .../termsadmission/TermsAdmission.php         |  21 +-
 .../termsadmission/templates/configure.php    |   7 +-
 .../timedadmission/TimedAdmission.php         |  42 +-
 .../timedadmission/templates/configure.php    |  50 +-
 lib/classes/CoursesetModel.php                |   4 +-
 lib/classes/JsonApi/RouteMap.php              |  26 +
 .../Routes/Admission/AdmissionRulesCreate.php |  50 ++
 .../Routes/Admission/AdmissionRulesDelete.php |  38 +
 .../Routes/Admission/AdmissionRulesIndex.php  |  24 +
 .../Routes/Admission/AdmissionRulesShow.php   |  33 +
 .../Routes/Admission/AdmissionRulesUpdate.php |  60 ++
 .../JsonApi/Routes/Admission/Authority.php    |  75 ++
 .../Admission/AvailableCoursesIndex.php       |  40 +
 .../Routes/Admission/CourseSetsCreate.php     |  68 ++
 .../Routes/Admission/CourseSetsDelete.php     |  37 +
 .../Routes/Admission/CourseSetsShow.php       |  35 +
 .../Routes/Admission/CourseSetsUpdate.php     |  73 ++
 .../Admission/RuleCompatibilityIndex.php      |  20 +
 .../JsonApi/Routes/UserFilters/Authority.php  |  18 +
 .../UserFilters/UserFilterFieldsIndex.php     |  30 +
 .../UserFilters/UserFilterFieldsShow.php      |  35 +
 .../Routes/UserFilters/UserFiltersCreate.php  |  61 ++
 .../Routes/UserFilters/UserFiltersDelete.php  |  38 +
 .../Routes/UserFilters/UserFiltersShow.php    |  31 +
 .../Routes/UserFilters/UserFiltersUpdate.php  |  68 ++
 lib/classes/JsonApi/SchemaMap.php             |   5 +
 lib/classes/JsonApi/Schemas/AdmissionRule.php |  34 +
 lib/classes/JsonApi/Schemas/CourseSet.php     | 166 ++++
 lib/classes/JsonApi/Schemas/UserFilter.php    |  45 ++
 .../JsonApi/Schemas/UserFilterField.php       |  67 ++
 lib/classes/StudipAutoloader.php              |  13 +
 lib/classes/admission/AdmissionRule.php       | 131 +++-
 lib/classes/admission/AdmissionUserList.php   |  13 +-
 lib/classes/admission/UserFilter.php          |   1 +
 lib/classes/admission/UserFilterField.php     |  36 +-
 .../assets/javascripts/bootstrap/admission.js |  31 +-
 resources/assets/javascripts/lib/admission.js |  16 +
 .../assets/stylesheets/scss/admission.scss    |  64 ++
 resources/assets/stylesheets/studip.scss      |   2 +-
 resources/vue/components/StudipUserFilter.vue | 128 ++++
 .../admission/AdmissionRuleConfig.vue         |  89 +++
 .../admission/AdmissionRuleTypeSelector.vue   | 113 +++
 .../admission/ConditionalAdmission.vue        | 194 +++++
 .../admission/ConfigureCourseSet.vue          | 712 ++++++++++++++++++
 .../admission/CourseMemberAdmission.vue       | 113 +++
 .../components/admission/LimitedAdmission.vue |  65 ++
 .../components/admission/LockedAdmission.vue  |  42 ++
 .../ParticipantRestrictedAdmission.vue        |  91 +++
 .../admission/PasswordAdmission.vue           |  83 ++
 .../admission/PreferentialAdmission.vue       | 120 +++
 .../components/admission/TermsAdmission.vue   |  57 ++
 .../components/admission/TimedAdmission.vue   |  83 ++
 .../vue/components/admission/ValidityTime.vue |  69 ++
 resources/vue/mixins/AdmissionRuleMixin.js    |  61 ++
 templates/userfilter/display.php              |   7 +-
 76 files changed, 3872 insertions(+), 771 deletions(-)
 delete mode 100644 app/views/admission/courseset/configure.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AdmissionRulesCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AdmissionRulesDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AdmissionRulesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AdmissionRulesShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AdmissionRulesUpdate.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/Authority.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/AvailableCoursesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/CourseSetsCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/CourseSetsDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/CourseSetsShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/CourseSetsUpdate.php
 create mode 100644 lib/classes/JsonApi/Routes/Admission/RuleCompatibilityIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/Authority.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsShow.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFiltersShow.php
 create mode 100644 lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
 create mode 100644 lib/classes/JsonApi/Schemas/AdmissionRule.php
 create mode 100644 lib/classes/JsonApi/Schemas/CourseSet.php
 create mode 100644 lib/classes/JsonApi/Schemas/UserFilter.php
 create mode 100644 lib/classes/JsonApi/Schemas/UserFilterField.php
 create mode 100644 resources/vue/components/StudipUserFilter.vue
 create mode 100644 resources/vue/components/admission/AdmissionRuleConfig.vue
 create mode 100644 resources/vue/components/admission/AdmissionRuleTypeSelector.vue
 create mode 100644 resources/vue/components/admission/ConditionalAdmission.vue
 create mode 100644 resources/vue/components/admission/ConfigureCourseSet.vue
 create mode 100644 resources/vue/components/admission/CourseMemberAdmission.vue
 create mode 100644 resources/vue/components/admission/LimitedAdmission.vue
 create mode 100644 resources/vue/components/admission/LockedAdmission.vue
 create mode 100644 resources/vue/components/admission/ParticipantRestrictedAdmission.vue
 create mode 100644 resources/vue/components/admission/PasswordAdmission.vue
 create mode 100644 resources/vue/components/admission/PreferentialAdmission.vue
 create mode 100644 resources/vue/components/admission/TermsAdmission.vue
 create mode 100644 resources/vue/components/admission/TimedAdmission.vue
 create mode 100644 resources/vue/components/admission/ValidityTime.vue
 create mode 100644 resources/vue/mixins/AdmissionRuleMixin.js

diff --git a/app/controllers/admission/courseset.php b/app/controllers/admission/courseset.php
index e694a2455f2..90ae5f54d08 100644
--- a/app/controllers/admission/courseset.php
+++ b/app/controllers/admission/courseset.php
@@ -258,6 +258,65 @@ class Admission_CoursesetController extends AuthenticatedController
             $tpl->set_attribute('rights', false);
         }
         $this->instTpl = $tpl->render();
+
+        $this->semesters = array_reverse(array_map(
+            fn ($s) => $s->toArray(),
+            Semester::getAll()
+        ));
+
+        if ($GLOBALS['perm']->have_perm('root')) {
+            $this->isearch = new StandardSearch('Institut_id');
+            $this->myinst = [];
+        } else {
+            $this->isearch = null;
+            $this->myinst = array_map(
+                fn ($i) => [
+                    'id' => $i['Institut_id'],
+                    'name' => $i['Name'],
+                    'faculty' => $i['is_fak'] ? null : $i['fakultaets_id']
+                ],
+                Institute::getMyInstitutes()
+            );
+        }
+
+        $props = [
+            'all-semesters' => $this->semesters,
+            'my-institutes'=> $this->myinst,
+            'my-user-lists' => array_values(
+                array_map(
+                    fn ($list) => [
+                        'id' => $list->getId(),
+                        'name' => $list->getName(),
+                        'factor' => $list->getFactor(),
+                        'count' => $list->getUserCount()
+                    ],
+                    $this->myUserlists
+                )
+            ),
+            'institute-search' => (string) $this->isearch
+        ];
+
+        if ($this->courseset) {
+            $props['course-set-id'] = $this->courseset->getId();
+        }
+
+        $this->render_vue_app(
+            Studip\VueApp::create('admission/ConfigureCourseSet')
+                ->withProps($props)
+        );
+
+        Helpbar::get()->addPlainText(
+            _('Regeln'),
+            _('Hier können Sie die Regeln, Eigenschaften und Zuordnungen des Anmeldesets bearbeiten.')
+        );
+        Helpbar::get()->addPlainText(
+            _('Info'),
+            _('Sie können das Anmeldeset allen Einrichtungen zuordnen, an denen Sie mindestens Lehrendenrechte haben.')
+        );
+        Helpbar::get()->addPlainText(
+            _('Sichtbarkeit'),
+            _('Alle Veranstaltungen der Einrichtungen, an denen Sie mindestens Lehrendenrechte haben, können zum Anmeldeset hinzugefügt werden.')
+        );
     }
 
     /**
diff --git a/app/controllers/admission/restricted_courses.php b/app/controllers/admission/restricted_courses.php
index 1a4a19149c2..eb4cc16c637 100644
--- a/app/controllers/admission/restricted_courses.php
+++ b/app/controllers/admission/restricted_courses.php
@@ -50,7 +50,7 @@ class Admission_RestrictedCoursesController extends AuthenticatedController
             $this->current_institut_id = 'all';
         }
         if (!$this->current_semester_id) {
-            $this->current_semester_id = $_SESSION['_default_sem'];
+            $this->current_semester_id = $_SESSION['_default_sem'] ?? Semester::findDefault()->id;
         } else {
             $_SESSION['_default_sem'] = $this->current_semester_id;
         }
diff --git a/app/views/admission/courseset/_institute_choose.php b/app/views/admission/courseset/_institute_choose.php
index 48af5f89a43..9a68b1aee52 100644
--- a/app/views/admission/courseset/_institute_choose.php
+++ b/app/views/admission/courseset/_institute_choose.php
@@ -27,17 +27,20 @@
             <?=_("Enthaltene Regeln:")?>
             <div class="hidden-no-js check_actions">
                 (<?= _('markieren') ?>:
-                <a onclick="STUDIP.Admission.checkUncheckAll('choose_rule_type', 'check')">
+                <button class="as-link" onclick="return STUDIP.Admission.checkUncheckAll('choose_rule_type', 'check')"
+                        title="<?= _('Alle Regeltypen auswählen') ?>">
                     <?= _('alle') ?>
-                </a>
+                </button>
                 |
-                <a onclick="STUDIP.Admission.checkUncheckAll('choose_rule_type', 'uncheck')">
+                <button class="as-link" onclick="return STUDIP.Admission.checkUncheckAll('choose_rule_type', 'uncheck')"
+                        title="<?= _('Keinen Regeltyp auswählen') ?>">
                     <?= _('keine') ?>
-                </a>
+                </button>
                 |
-                <a onclick="STUDIP.Admission.checkUncheckAll('choose_rule_type', 'invert')">
+                <button class="as-link" onclick="return STUDIP.Admission.checkUncheckAll('choose_rule_type', 'invert')"
+                        title="<?= _('Aktuelle Auswahl der Regeltypen umkehren') ?>">
                     <?= _('Auswahl umkehren') ?>
-                </a>)
+                </button>)
             </div>
         </section>
 
diff --git a/app/views/admission/courseset/configure.php b/app/views/admission/courseset/configure.php
deleted file mode 100644
index cc2209c180e..00000000000
--- a/app/views/admission/courseset/configure.php
+++ /dev/null
@@ -1,268 +0,0 @@
-<?php
-/**
- * @var CourseSet $courseset
- * @var array $flash
- * @var Admission_CoursesetController|Course_AdmissionController $controller
- * @var bool $instant_course_set_view
- * @var array $myInstitutes
- * @var array $selectedInstitutes
- * @var QuickSearch $instSearch
- * @var string $instTpl
- * @var string $coursesTpl
- * @var string $selectedSemester
- * @var AdmissionUserList[] $myUserlists
- */
-use Studip\Button, Studip\LinkButton;
-
-Helpbar::get()->addPlainText(_('Regeln'), _('Hier können Sie die Regeln, Eigenschaften und Zuordnungen des Anmeldesets bearbeiten.'));
-Helpbar::get()->addPlainText(_('Info'), _('Sie können das Anmeldeset allen Einrichtungen zuordnen, an denen Sie mindestens Lehrendenrechte haben.'));
-Helpbar::get()->addPlainText(_('Sichtbarkeit'), _('Alle Veranstaltungen der Einrichtungen, an denen Sie mindestens Lehrendenrechte haben, können zum Anmeldeset hinzugefügt werden.'));
-
-// Load assigned course IDs.
-$courseIds = $courseset ? $courseset->getCourses() : [];
-// Load assigned user list IDs.
-$userlistIds = $courseset ? $courseset->getUserlists() : [];
-
-if (isset($flash['error'])) {
-    echo MessageBox::error($flash['error']);
-}
-?>
-<div class="hidden-alert" style="display:none">
-    <?= MessageBox::info(_("Diese Daten sind noch nicht gespeichert."));?>
-</div>
-<h1><?= $courseset ? _('Anmeldeset bearbeiten') : _('Anmeldeset anlegen') ?></h1>
-<form class="default" id="courseset-form" action="<?= $controller->url_for(!$instant_course_set_view ?
-    'admission/courseset/save/' . ($courseset ? $courseset->getId() : '') :
-    'course/admission/save_courseset/' . $courseset->getId()) ?>" method="post">
-    <fieldset>
-        <legend><?= _('Grunddaten') ?></legend>
-        <label>
-            <span class="required"><?= _('Name des Anmeldesets') ?></span>
-            <input type="text" maxlength="255" name="name"
-                   value="<?= $courseset ? htmlReady($courseset->getName()) : '' ?>"
-                   required aria-required="true"/>
-        </label>
-        <? if (!$courseset || ($courseset->isUserAllowedToEdit($GLOBALS['user']->id) && !$instant_course_set_view)) : ?>
-            <label for="private">
-                <?= _('Sichtbarkeit') ?>
-            </label>
-            <input type="checkbox" id="private" name="private"<?= $courseset ? ($courseset->getPrivate() ? ' checked="checked"' : '') : 'checked' ?>/>
-            <?= _('Dieses Anmeldeset soll nur für mich selbst und alle Administratoren sichtbar und benutzbar sein.') ?>
-        <?  endif ?>
-        <? if ($courseset) : ?>
-        <label>
-            <?= _('Besitzer des Anmeldesets') ?>
-        </label>
-        <div>
-            <? $user = User::find($courseset->getUserId()) ?>
-            <? if (isset($user)) : ?>
-                <a target="_blank" href="<?= $controller->url_for('profile', ['username' => $user->username]) ?>" >
-                    <?= htmlReady($user->getFullName()) ?> (<?= htmlReady($user->username) ?>)
-                </a>
-            <? else : ?>
-                <?= _('unbekannt') ?>
-            <? endif ?>
-        </div>
-        <? endif ;?>
-        <label for="institutes">
-            <span class="required"><?= _('Einrichtungszuordnung') ?></span>
-        </label>
-        <? if ($GLOBALS['perm']->have_perm('admin') || $GLOBALS['perm']->have_perm('dozent') && Config::get()->ALLOW_DOZENT_COURSESET_ADMIN) : ?>
-            <div id="institutes">
-            <?php if ($myInstitutes) { ?>
-                <?php if ($instSearch) { ?>
-                    <?= $instTpl ?>
-                <?php } else { ?>
-                    <?php foreach ($myInstitutes as $institute) { ?>
-                        <?php if (count($myInstitutes) !== 1) { ?>
-                    <input type="checkbox" name="institutes[]" value="<?= $institute['Institut_id'] ?>"
-                        <?= !empty($selectedInstitutes[$institute['Institut_id']]) ? 'checked' : '' ?>
-                        class="institute" onclick="STUDIP.Admission.getCourses(
-                        '<?= $controller->url_for('admission/courseset/instcourses', $courseset ? $courseset->getId() : '') ?>')"/>
-                        <?php } else { ?>
-                    <input type="hidden" name="institutes[]" value="<?= $institute['Institut_id'] ?>"/>
-                        <?php } ?>
-                        <?= htmlReady($institute['Name']) ?>
-                    <br/>
-                    <?php } ?>
-                <?php } ?>
-            <?php } else { ?>
-                <?php if ($instSearch) { ?>
-                <div id="institutes">
-                    <?= Icon::create('arr_2down', Icon::ROLE_SORT)->asImg([
-                        'title' => _('Einrichtung hinzufügen'),
-                        'alt' => _('Einrichtung hinzufügen'),
-                        'onclick' => "STUDIP.Admission.updateInstitutes($('input[name=\"institute_id\"]').val(), '"  .$controller->url_for('admission/courseset/institutes',$courseset?$courseset->getId() : '') . "', '" . $controller->url_for('admission/courseset/instcourses',$courseset?$courseset->getId() : '') . "', 'add')"
-                    ]) ?>
-                    <?= $instSearch ?>
-                    <?= Icon::create('search')->asImg(['title' => _("Suche starten")])?>
-                </div>
-                <i><?=  _('Sie haben noch keine Einrichtung ausgewählt. Benutzen Sie obige Suche, um dies zu tun.') ?></i>
-                <?php } else { ?>
-                <i><?=  _('Sie sind keiner Einrichtung zugeordnet.') ?></i>
-                <?php } ?>
-            <?php } ?>
-            </div>
-        <? else : ?>
-            <? foreach (SimpleCollection::createFromArray($selectedInstitutes)->orderBy('Name') as $institute) : ?>
-                <?= htmlReady($institute['Name']) ?>
-                <br>
-            <?  endforeach ?>
-        <?  endif ?>
-    </fieldset>
-    <fieldset>
-        <legend><?= _('Veranstaltungen') ?></legend>
-        <? if (!$instant_course_set_view) : ?>
-            <label>
-                <?= _('Semester') ?>
-                <select name="semester" onchange="STUDIP.Admission.getCourses('<?= $controller->url_for('admission/courseset/instcourses', $courseset ? $courseset->getId() : '') ?>')">
-                    <?php foreach(array_reverse(Semester::getAll(), true) as $id => $semester) { ?>
-                    <option value="<?= $id ?>"<?= $id === $selectedSemester ? ' selected' : '' ?>>
-                        <?= htmlReady($semester->name) ?>
-                    </option>
-                    <?php } ?>
-                </select>
-            </label>
-            <label>
-                <?= _('Filter auf Name/Nummer/Lehrperson') ?><br>
-                <input style="display:inline-block" type="text" onKeypress="if (event.which==13) return STUDIP.Admission.getCourses('<?= $controller->url_for('admission/courseset/instcourses', $courseset ? $courseset->getId() : '') ?>')"
-                       value="<?= htmlReady($current_course_filter ?? '') ?>" name="course_filter" >
-                <?= Icon::create('search')->asImg([
-                    'title' => _("Veranstaltungen anzeigen"),
-                    'onClick' => "return STUDIP.Admission.getCourses('" . $controller->url_for('admission/courseset/instcourses', $courseset ? $courseset->getId() : '') ."')"
-                ]) ?>
-            </label>
-            <div id="instcourses">
-            <?= $coursesTpl; ?>
-            </div>
-            <? if (count($courseIds)) : ?>
-                <div>
-                        <?= LinkButton::create(_('Ausgewählte Veranstaltungen konfigurieren'),
-                            $controller->url_for('admission/courseset/configure_courses/' . $courseset->getId()),
-                            ['data-dialog' => 'size=big', 'class' => 'autosave']
-                            ); ?>
-                        <? if ($num_applicants = $courseset->getNumApplicants()) :?>
-                        <?= LinkButton::create(sprintf(_('Liste der Anmeldungen (%s Nutzer)'), $num_applicants),
-                            $controller->url_for('admission/courseset/applications_list/' . $courseset->getId()),
-                            ['data-dialog' => '', 'class' => 'autosave']
-                            ); ?>
-                        <?= LinkButton::create(_('Nachricht an alle Angemeldeten'),
-                                $controller->url_for('admission/courseset/applicants_message/' . $courseset->getId()),
-                                ['data-dialog' => '', 'class' => 'autosave']
-                            ); ?>
-                        <? endif ?>
-                </div>
-            <? endif ?>
-        <? else :?>
-            <? if (count($courseIds) > 100) :?>
-                <?= sprintf(_("%s zugewiesene Veranstaltungen"), count($courseIds)) ?>
-            <? else : ?>
-            <?
-            Course::findEachBySQL(
-                function($c) {
-                    echo htmlReady($c->getFullName('number-name-semester'));
-                    echo '<br>';
-                },
-                "JOIN `semester_courses`
-                ON `seminare`.`seminar_id` = `semester_courses`.`course_id`
-                JOIN `semester_data` USING (`semester_id`)
-                WHERE `seminare`.`seminar_id` IN ( :course_ids )
-                ORDER BY `semester_data`.`beginn`, `VeranstaltungsNummer`, `Name`",
-                ['course_ids' => $courseIds],
-            )
-            ?>
-            <? endif ?>
-        <? endif ?>
-    </fieldset>
-    <fieldset>
-        <legend><?= _('Anmelderegeln') ?></legend>
-        <div id="rules">
-            <?php if ($courseset) { ?>
-            <div id="rulelist">
-                <?php foreach ($courseset->getAdmissionRules() as $rule) { ?>
-                    <?= $this->render_partial('admission/rule/save', ['rule' => $rule]) ?>
-                <?php } ?>
-            </div>
-            <?php } else { ?>
-            <span id="norules">
-                <i><?= _('Sie haben noch keine Anmelderegeln festgelegt.') ?></i>
-            </span>
-            <br/>
-            <?php } ?>
-            <div style="clear: both;">
-                    <?= LinkButton::create(_('Anmelderegel hinzufügen'),
-                        $controller->url_for('admission/rule/select_type' . ($courseset ? '/'.$courseset->getId() : '')),
-                        [
-                            'onclick' => "return STUDIP.Admission.selectRuleType(this)"
-                            ]
-                        ); ?>
-            </div>
-        </div>
-    </fieldset>
-    <div class="hidden-alert" style="display:none">
-        <?= MessageBox::info(_("Diese Daten sind noch nicht gespeichert."));?>
-    </div>
-    <fieldset>
-        <legend><?= _('Weitere Daten') ?></legend>
-   <? if (!$instant_course_set_view) : ?>
-
-    <? if ($courseset && $courseset->getSeatDistributionTime()) :?>
-        <label>
-            <?= _('Personenlisten zuordnen') ?>
-            </label>
-            <?php if ($myUserlists) { ?>
-                <?php
-                foreach ($myUserlists as $list) {
-                    $checked = '';
-                    if (in_array($list->getId(), $userlistIds)) {
-                        $checked = ' checked="checked"';
-                    }
-                ?>
-                <input type="checkbox" name="userlists[]" value="<?= $list->getId() ?>"<?= $checked ?>/> <?= $list->getName() ?><br/>
-                <?php } ?>
-
-            <?php } else { ?>
-                <i><?=  _('Sie haben noch keine Personenlisten angelegt.') ?></i>
-            <?php
-            }?>
-            <div>
-                    <?= LinkButton::create(_('Liste der Nutzer'),
-                        $controller->url_for('admission/courseset/factored_users/' . $courseset->getId()),
-                        ['data-dialog' => '']
-                        ); ?>
-            </div>
-            <?php
-            // Keep lists that were assigned by other users.
-            foreach ($userlistIds as $list) {
-                if (!in_array($list, array_keys($myUserlists))) {
-            ?>
-            <input type="hidden" name="userlists[]" value="<?= $list ?>"/>
-            <?php
-                }
-            }
-            ?>
-        <? endif ?>
-        <? endif ?>
-        <label for="infotext">
-            <?= _('Weitere Hinweise für die Teilnehmenden') ?>
-        </label>
-        <textarea cols="60" rows="3" name="infotext"><?= $courseset ? htmlReady($courseset->getInfoText()) : '' ?></textarea>
-    </fieldset>
-
-    <footer class="submit_wrapper" data-dialog-button>
-        <?= CSRFProtection::tokenTag() ?>
-        <?= Button::createAccept(_('Speichern'), 'submit',
-            $instant_course_set_view ? ['data-dialog' => ''] : []) ?>
-        <?php if (Request::option('is_copy')) : ?>
-            <?= LinkButton::createCancel(_('Abbrechen'),
-                URLHelper::getURL('dispatch.php/admission/courseset/delete/' . $courseset->getId(),
-                ['really' => 1])) ?>
-        <?php else : ?>
-            <?= LinkButton::createCancel(_('Abbrechen'), $controller->url_for('admission/courseset')) ?>
-        <?php endif ?>
-    </footer>
-
-</form>
-<? if (Request::get('is_copy')) :?>
-    <script>STUDIP.Admission.toggleNotSavedAlert();</script>
-<? endif ?>
diff --git a/app/views/admission/rule/configure.php b/app/views/admission/rule/configure.php
index b00c27b248a..64908ee93cd 100644
--- a/app/views/admission/rule/configure.php
+++ b/app/views/admission/rule/configure.php
@@ -8,19 +8,4 @@ use Studip\Button, Studip\LinkButton;
  */
 ?>
 <div id="errormessage"></div>
-<form action="<?= $controller->url_for('admission/rule/save', get_class($rule), $rule->getId()) ?>"
-      id="ruleform" class="default"
-      onsubmit="return STUDIP.Admission.checkAndSaveRule(
-          '<?= $rule->getId() ?>',
-              'errormessage',
-              '<?= $controller->url_for('admission/rule/validate', get_class($rule), $rule->getId()) ?>',
-              'rules',
-              '<?= $controller->url_for('admission/rule/save', get_class($rule), $rule->getId()) ?>'
-          )">
     <?= $ruleTemplate ?>
-    <footer data-dialog-button>
-        <input type="hidden" id="action" name="action" value="">
-        <?= Button::createAccept(_('Speichern'), 'submit') ?>
-        <?= LinkButton::createCancel(_('Abbrechen'), 'cancel') ?>
-    </footer>
-</form>
diff --git a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
index 640e8006014..ab26cc29948 100644
--- a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
+++ b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
@@ -225,7 +225,8 @@ class ConditionalAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `conditionaladmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->startTime = $current['start_time'];
             $this->endTime = $current['end_time'];
@@ -243,6 +244,8 @@ class ConditionalAdmission extends AdmissionRule
                     $this->ungrouped_conditions[$condition['filter_id']] = $currentCondition;
                 }
             }
+        } else {
+            $this->id = $this->generateId('conditionaladmissions');
         }
     }
 
@@ -352,26 +355,50 @@ class ConditionalAdmission extends AdmissionRule
     {
         UserFilterField::getAvailableFilterFields();
         parent::setAllData($data);
+
         $this->conditions = [];
         $this->ungrouped_conditions = [];
         $this->conditiongroups = [];
         $this->quota = [];
-        foreach ($data['conditions'] as $ser_con) {
-            $condition = ObjectBuilder::build($ser_con, 'UserFilter');
+        foreach ($data['conditions'] as $con) {
+            $condition = new UserFilter();
+            foreach ($con['attributes']['fields'] as $field) {
+                $classname = $field['attributes']['type'];
+                $obj = !empty($field['attributes']['typeparam'])
+                    ? new $classname($field['attributes']['typeparam'])
+                    : new $classname();
+                $obj->setCompareOperator($field['attributes']['compare-operator']);
+                $obj->setValue($field['attributes']['value']);
+                $condition->addField($obj);
+            }
             $this->addCondition($condition, $data['conditiongroup_'.$condition->getId()], $data['quota_'.$data['conditiongroup_'.$condition->getId()]] ?? 0);
         }
-        foreach ($this->getConditiongroups() as $conditiongroup_id => $conditions) {
-            if (mb_strlen($conditiongroup_id) < 32) {
-                $group = md5(uniqid('conditiongroups' . microtime(), true));
 
-                $this->conditiongroups[$group] = $this->conditiongroups[$conditiongroup_id];
-                unset($this->conditiongroups[$conditiongroup_id]);
+        foreach ($data['grouped-conditions'] as $group) {
+            if ($group['id'] === '') {
+                $id = $group['id'] ?: md5(uniqid('conditiongroups' . microtime(), true));
+            }
+
+            $this->conditiongroups[$id] = [];
+            $this->quota[$id] = $group['quota'];
+
+            foreach ($group['conditions'] as $con) {
+                $condition = new UserFilter();
+                foreach ($con['attributes']['fields'] as $field) {
+                    $classname = $field['attributes']['type'];
+                    $obj = !empty($field['attributes']['typeparam'])
+                        ? new $classname($field['attributes']['typeparam'])
+                        : new $classname();
+                    $obj->setCompareOperator($field['attributes']['compare-operator']);
+                    $obj->setValue($field['attributes']['value']);
+                    $condition->addField($obj);
+                }
 
-                $this->quota[$group] = $this->quota[$conditiongroup_id];
-                unset($this->quota[$conditiongroup_id]);
+                $this->conditiongroups[$id][] = $condition;
             }
         }
-        if (count($this->getConditiongroups()) && $data['conditiongroups_allowed']) {
+
+        if (count($this->conditiongroups) && $data['conditiongroups_allowed']) {
             $this->conditiongroups_allowed = true;
         }
 
@@ -560,4 +587,75 @@ class ConditionalAdmission extends AdmissionRule
         parent::setSiblings($siblings);
         $this->conditiongroups_allowed = null;
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        $ungrouped = [];
+        foreach ($this->getUngroupedConditions() as $one) {
+
+            $fields = [];
+            foreach ($one->getFields() as $field) {
+                $fields[] = [
+                    'attributes' => [
+                        'type' => get_class($field),
+                        'id' => $field->getId(),
+                        'compare-operator' => $field->getCompareOperator(),
+                        'value' => $field->getValue()
+                    ]
+                ];
+            }
+
+            $ungrouped[] = [
+                'attributes' => [
+                    'text' => $one->toString(),
+                    'fields' => $fields
+                ]
+            ];
+        }
+
+        $groups = [];
+
+        foreach ($this->getConditionGroups() as $id => $conditions) {
+            $group = [
+                'id' => $id,
+                'quota' => $this->getQuota($id),
+                'conditions' => []
+            ];
+            foreach ($conditions as $one) {
+                $fields = [];
+                foreach ($one->getFields() as $field) {
+                    $fields[] = [
+                        'attributes' => [
+                            'type' => get_class($field),
+                            'id' => $field->getId(),
+                            'compare-operator' => $field->getCompareOperator(),
+                            'value' => $field->getValue()
+                        ]
+                    ];
+                }
+
+                $group['conditions'][] = [
+                    'attributes' => [
+                        'text' => $one->toString(),
+                        'fields' => $fields
+                    ]
+                ];
+            }
+
+            $groups[] = $group;
+        }
+
+        return array_merge(
+            parent::getPayload(),
+            [
+                'quota' => $this->quota,
+                'conditiongroups-allowed' => $this->conditiongroups_allowed ? 'true' : 'false',
+                'conditions' => $ungrouped,
+                'grouped-conditions' => $groups
+            ]
+        );
+    }
 }
diff --git a/lib/admissionrules/conditionaladmission/templates/configure.php b/lib/admissionrules/conditionaladmission/templates/configure.php
index daf12466b98..d4e7a14cd6c 100644
--- a/lib/admissionrules/conditionaladmission/templates/configure.php
+++ b/lib/admissionrules/conditionaladmission/templates/configure.php
@@ -1,82 +1,3 @@
-<?php
-use Studip\Button, Studip\LinkButton;
-?>
-
-<h3><?= htmlReady($rule->getName()) ?></h3>
-<?= $tpl ?>
-<br>
-<label for="conditionlist" class="caption">
-    <span class="required"><?= _('Anmeldebedingungen') ?></span>
-</label>
-
-<br>
-
-<a href="<?= URLHelper::getURL('dispatch.php/userfilter/filter/configure/condadmission_conditions') ?>" onclick="return STUDIP.UserFilter.configureCondition('condition', this.href)">
-    <?= Icon::create('add', 'clickable', tooltip2(_('Bedingung hinzufügen')))->asImg() ?>
-    <?= _('Bedingung hinzufügen') ?>
-</a>
-
-<br>
-
-<div id="condadmission_conditions">
-    <span class="nofilter" style="<?=(!$rule->getUngroupedConditions() && !$rule->getConditiongroups()) ? '' : 'display: none'?>">
-        <i><?= _('Sie haben noch keine Bedingungen festgelegt.'); ?></i>
-    </span>
-    <div class="userfilter" style="<?=(!$rule->getUngroupedConditions() || !$rule->getConditiongroups()) ? '' : 'display: none'?>">
-        <? if ($rule->conditiongroupsAllowed()): ?>
-        <div class="grouped_conditions_template" id="new_conditiongroup" style="margin-bottom: 5px; display: none">
-            <div class="condition_list">
-                <?=_('Kontingent:')?> <input type="text" name="quota" size="5"> <?=_('Prozent')?>
-            </div>
-            <?= Button::create(_('Kontingent aufheben'), 'ungroup_conditions', ['class' => 'ungroup_conditions', 'onclick' => 'return STUDIP.UserFilter.ungroupConditions(this)']) ?>
-        </div>
-        <? else: ?>
-            <? $rule->removeConditiongroups(); ?>
-            <div id="no_conditiongroups"></div>
-        <? endif; ?>
-        <div class="ungrouped_conditions">
-            <div class="condition_list">
-            <? foreach ($rule->getUngroupedConditions() as $condition): ?>
-                <? $condition->show_user_count = true; ?>
-                <div class="condition" id="condition_<?= $condition->getId() ?>">
-                    <? if ($rule->conditiongroupsAllowed()): ?>
-                        <input type="checkbox" name="conditions_checkbox[]" value="<?= htmlReady(ObjectBuilder::exportAsJson($condition)) ?>">
-                    <? endif; ?>
-                    <?= $condition->toString() ?>
-                    <a href="#" onclick="return STUDIP.UserFilter.removeConditionField($(this).parent())"
-                        class="conditionfield_delete">
-                    <?= Icon::create('trash', 'clickable')->asImg(); ?></a>
-                    <input type="hidden" name="conditions[]" value="<?= htmlReady(ObjectBuilder::exportAsJson($condition)) ?>">
-                    <input type="hidden" name="conditiongroup_<?=$condition->getId()?>" value="">
-                </div>
-            <? endforeach; ?>
-            </div>
-        </div>
-        <? if ($rule->conditiongroupsAllowed()): ?>
-            <input type="hidden" name="conditiongroups_allowed" value="1">
-            <?= Button::create(_('Kontingent erstellen'), 'group_conditions', ['class' => 'group_conditions', 'onclick' => 'return STUDIP.UserFilter.groupConditions()', 'style' => $rule->getUngroupedConditions() ? '' : 'display: none']) ?>
-            <? foreach ($rule->getConditiongroups() as $conditiongroup_id => $conditiongroup): ?>
-            <div class="grouped_conditions" id="conditiongroup_<?=$conditiongroup_id?>" style="margin-bottom: 5px">
-                <div class="condition_list">
-                    <?=_('Kontingent:')?> <input type="text" name="quota_<?=$conditiongroup_id?>" value="<?=$rule->getQuota($conditiongroup_id)?>" size="5"> <?=_('Prozent')?>
-                    <? foreach ($conditiongroup as $condition): ?>
-                        <? $condition->show_user_count = true; ?>
-                        <div class="condition" id="condition_<?= $condition->getId() ?>">
-                            <input type="checkbox" name="conditions_checkbox[]" value="<?= htmlReady(ObjectBuilder::exportAsJson($condition)) ?>" style="display: none">
-                            <?= $condition->toString() ?>
-                            <a href="#" onclick="return STUDIP.UserFilter.removeConditionField($(this).parent())"
-                                        class="conditionfield_delete">
-                            <?= Icon::create('trash', 'clickable')->asImg(); ?></a>
-                            <input type="hidden" name="conditions[]" value="<?= htmlReady(ObjectBuilder::exportAsJson($condition)) ?>">
-                            <input type="hidden" name="conditiongroup_<?=$condition->getId()?>" value="<?= $conditiongroup_id ?>">
-                        </div>
-                    <? endforeach; ?>
-                </div>
-                <?= Button::create(_('Kontingent aufheben'), 'ungroup_conditions', ['class' => 'ungroup_conditions', 'onclick' => 'return STUDIP.UserFilter.ungroupConditions(this)']) ?>
-            </div>
-            <? endforeach; ?>
-        <? endif; ?>
-    </div>
+<div data-admission-rule="ConditionalAdmission">
+    <conditional-admission></conditional-admission>
 </div>
-
-<br>
diff --git a/lib/admissionrules/conditionaladmission/templates/info.php b/lib/admissionrules/conditionaladmission/templates/info.php
index 2094e3a9ff1..f380647007c 100644
--- a/lib/admissionrules/conditionaladmission/templates/info.php
+++ b/lib/admissionrules/conditionaladmission/templates/info.php
@@ -39,22 +39,18 @@ if ($rule->getStartTime() && $rule->getEndTime()) {
         'erfüllt sein:') ?>
     <br>
     <ul id="conditions">
-    <? foreach ($rule->getConditiongroups() as $conditiongroup_id => $conditions): ?>
-        <? if ($rule->conditiongroupsAllowed()): ?>
-            <li>
-                <i><?= sprintf(_('Kontingent: %s Prozent'), $rule->getQuota($conditiongroup_id)) ?></i>
-            </li>
-        <? endif; ?>
+    <? foreach ($rule->getConditionGroups() as $conditiongroup_id => $conditions): ?>
         <li>
+            <i><?= sprintf(_('Kontingent: %s Prozent'), $rule->getQuota($conditiongroup_id)) ?></i>
             <ul id="conditiongroup_<?=$conditiongroup_id?>">
-            <? foreach ($conditions as $condition): ?>
-                <li id="condition_<?= $condition->getId() ?>">
-                    <i><?= $condition->toString() ?></i>
-                </li>
-            <? endforeach; ?>
+                <? foreach ($conditions as $condition): ?>
+                    <li id="condition_<?= $condition->getId() ?>">
+                        <i><?= $condition->toString() ?></i>
+                    </li>
+                <? endforeach; ?>
             </ul>
         </li>
-        
+
     <? endforeach; ?>
     </ul>
 <? endif; ?>
diff --git a/lib/admissionrules/coursememberadmission/CourseMemberAdmission.php b/lib/admissionrules/coursememberadmission/CourseMemberAdmission.php
index 139c36040bd..00bccee7d27 100644
--- a/lib/admissionrules/coursememberadmission/CourseMemberAdmission.php
+++ b/lib/admissionrules/coursememberadmission/CourseMemberAdmission.php
@@ -88,10 +88,13 @@ class CourseMemberAdmission extends AdmissionRule
         $tpl = $GLOBALS['template_factory']->open('admission/rules/configure');
         $tpl->set_attribute('rule', $this);
 
+        $search = new StandardSearch('Seminar_id');
+
         return $this->getTemplateFactory()->render('configure', [
             'rule'    => $this,
             'tpl'     => $tpl->render(),
             'courses' => $this->getDecodedCourses(),
+            'search'  => $search
         ]);
     }
 
@@ -104,12 +107,15 @@ class CourseMemberAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `coursememberadmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetchOne()) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->startTime = $current['start_time'];
             $this->endTime = $current['end_time'];
             $this->courses_to_add = $current['courses'];
             $this->modus = (int) $current['modus'];
+        } else {
+            $this->id = $this->generateId('coursememberadmissions');
         }
     }
 
@@ -154,10 +160,8 @@ class CourseMemberAdmission extends AdmissionRule
      */
     public function setAllData($data)
     {
-        parent::setAllData($data);
-
         $this->modus = (int) $data['modus'];
-        $this->courses_to_add = json_encode(array_keys($data['courses_to_add']));
+        $this->courses_to_add = json_encode(array_map(fn ($course) => $course['id'], $data['courses']));
         return $this;
     }
 
@@ -257,4 +261,23 @@ class CourseMemberAdmission extends AdmissionRule
     {
         return new Flexi\Factory(__DIR__ . '/templates/');
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'courses' => array_map(
+                    fn ($course) => ['id' => $course->id, 'name' => $course->getFullname()],
+                    $this->getDecodedCourses()
+                ),
+                'modus' => $this->modus,
+                'search' => (string) new StandardSearch('Seminar_id')
+            ]
+        );
+    }
+
 }
diff --git a/lib/admissionrules/coursememberadmission/templates/configure.php b/lib/admissionrules/coursememberadmission/templates/configure.php
index c5d3e4efad2..a86150b5877 100644
--- a/lib/admissionrules/coursememberadmission/templates/configure.php
+++ b/lib/admissionrules/coursememberadmission/templates/configure.php
@@ -1,127 +1,3 @@
-<h3><?= htmlReady($rule->getName()) ?></h3>
-
-<?= $tpl ?>
-
-<input type="hidden" name="search_sem_qs_choose" value="title_lecturer_number">
-
-<? foreach ($courses as $course) : ?>
-    <input type="hidden" name="mandatory_course_id_old[]" value="<?= htmlReady($course->id) ?>">
-
-    <label class="caption">
-        <?= _('Mitgliedschaft in folgender Veranstaltung überprüfen') ?>:
-    </label>
-    <p>
-        <?=htmlReady($course->getFullName('number-name-semester'));?>
-        <a href="<?=URLHelper::getLink('dispatch.php/course/details/index/' . $course->id) ?>"  data-dialog>
-            <?= Icon::create('info-circle')->asImg([
-                'title' =>_('Veranstaltungsdetails aufrufen')
-            ]) ?>
-        </a>
-    </p>
-<? endforeach ?>
-
-<label class="caption">
-    <?= _('Modus') ?>:
-</label>
-<div>
-    <label>
-        <input type="radio" name="modus" value="0" <? if ($rule->modus == CourseMemberAdmission::MODE_MUST_BE_IN_COURSES) echo 'checked'; ?>>
-        <?=_("Mitgliedschaft ist in mindestens einer dieser Veranstaltungen notwendig")?>
-    </label>
-    <label>
-        <input type="radio" name="modus" value="1" <? if ($rule->modus == CourseMemberAdmission::MODE_MAY_NOT_BE_IN_COURSES) echo 'checked'; ?>>
-        <?=_("Mitgliedschaft ist in keiner dieser Veranstaltungen erlaubt")?>
-    </label>
+<div data-admission-rule="CourseMemberAdmission">
+    <course-member-admission></course-member-admission>
 </div>
-
-<label class="caption">
-    <?= _('Veranstaltung suchen') ?>:
-</label>
-
-<div style="display:flex; align-items: flex-start; column-gap: 1em; flex-wrap: wrap">
-
-    <?=
-    QuickSearch::get('mandatory_course_id', new SeminarSearch())
-        ->fireJSFunctionOnSelect('addcourse')
-        ->setInputStyle('flex: 0 0 40%')
-        ->render();
-    ?>
-
-    <div style="flex: 0 0 40%">
-        <?= Semester::getSemesterSelector(
-            ['name' => 'search_sem_sem'],
-            Semester::getIndexById($_SESSION['_default_sem'], false, !$GLOBALS['perm']->have_perm('admin')),
-            'key',
-            false
-        )?>
-    </div>
-    <br/><br/>
-    <ul>
-        <? foreach ($courses as $course) : ?>
-            <li>
-                <input type="hidden" id="<?= htmlReady($course->id) ?>"
-                       name="courses_to_add[<?= htmlReady($course->id) ?>]"
-                       value="<?= htmlReady($course->name) ?>">
-                <span><?= htmlReady($course->name) ?></span>
-                <a href="#" onclick="return removecourse('<?= htmlReady($course->id) ?>')">
-                    <?= Icon::create('trash') ?>
-                </a>
-            </li>
-        <? endforeach ?>
-    </ul>
-</div>
-
-<script>
-    $('#ruleform input[name="modus"]').on('change', function () {
-        const message = <?= json_encode([
-            _('Sie sind nicht in der Veranstaltung "%s" eingetragen.'),
-            _('Sie dürfen nicht in der Veranstaltung "%s" eingetragen sein.'),
-        ]) ?>;
-        console.log(this, this.value);
-        $('#ruleform textarea').text(message[this.value]);
-    }).filter(':checked').change();
-
-    function addcourse(id, title) {
-
-        if ($('input[name="courses_to_add[' + id + ']"]').length === 0) {
-            var wrapper = $('<li>');
-            var input = $('<input>')
-                .attr('id', id)
-                .attr('type', 'hidden')
-                .attr('name', 'courses_to_add['+ id + ']')
-                .attr('value', title);
-            wrapper.append(input);
-
-            var trash = $('<input>')
-                .attr('type', 'image')
-                .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg')
-                .attr('name', 'remove_[' + id + ']')
-                .attr('value', '1')
-                .attr('onclick', "return removecourse('" + id + "')");
-
-            var icon = $('<a>')
-                .attr('onclick', "return removecourse('" + id + "')")
-                .attr('href', '#');
-            var img = $('<img>')
-                .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg')
-                .attr('width', '16px')
-                .attr('height', '16px');
-            icon.append(img);
-
-            var nametext = $('<span>')
-                .html(title)
-                .text();
-            wrapper.append(nametext);
-            wrapper.append(icon);
-
-            $('input[name=mandatory_course_id_parameter]').parent().find('ul').append(wrapper);
-        }
-
-    }
-
-    function removecourse(id) {
-        $('input#' + id).parent().remove();
-        return false;
-    }
-
-</script>
diff --git a/lib/admissionrules/limitedadmission/LimitedAdmission.php b/lib/admissionrules/limitedadmission/LimitedAdmission.php
index d7f53c15017..c0339cc1b92 100644
--- a/lib/admissionrules/limitedadmission/LimitedAdmission.php
+++ b/lib/admissionrules/limitedadmission/LimitedAdmission.php
@@ -141,11 +141,14 @@ class LimitedAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `limitedadmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->startTime = $current['start_time'];
             $this->endTime = $current['end_time'];
             $this->maxNumber = $current['maxnumber'];
+        } else {
+            $this->id = $this->generateId('limitedadmissions');
         }
     }
 
@@ -279,6 +282,20 @@ class LimitedAdmission extends AdmissionRule
             return $message;
         }
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'maxnumber' => $this->getMaxNumber()
+            ]
+        );
+    }
+
 } /* end of class LimitedAdmission */
 
 ?>
diff --git a/lib/admissionrules/limitedadmission/templates/configure.php b/lib/admissionrules/limitedadmission/templates/configure.php
index 7a36147071a..5a2e0a9da47 100644
--- a/lib/admissionrules/limitedadmission/templates/configure.php
+++ b/lib/admissionrules/limitedadmission/templates/configure.php
@@ -1,7 +1,3 @@
-<h3><?= $rule->getName() ?></h3>
-<?= $tpl ?>
-<br/>
-<label for="maxnumber" class="caption">
-    <span class="required"><?= _('Maximale Anzahl erlaubter Anmeldungen') ?></span>
-    <input type="number" name="maxnumber" size="4" min="1" value="<?= $rule->getMaxNumber() ?>" required/>
-</label>
+<div data-admission-rule="LimitedAdmission">
+    <limited-admission></limited-admission>
+</div>
diff --git a/lib/admissionrules/lockedadmission/LockedAdmission.php b/lib/admissionrules/lockedadmission/LockedAdmission.php
index 92ef5137e40..afef13afd28 100644
--- a/lib/admissionrules/lockedadmission/LockedAdmission.php
+++ b/lib/admissionrules/lockedadmission/LockedAdmission.php
@@ -2,7 +2,7 @@
 
 /**
  * LockedAdmission.php
- * 
+ *
  * Represents a rule for completely locking courses for admission.
  *
  * This program is free software; you can redistribute it and/or
@@ -30,7 +30,7 @@ class LockedAdmission extends AdmissionRule
     {
         parent::__construct($ruleId, $courseSetId);
         $this->default_message = _('Die Anmeldung ist gesperrt.');
-        
+
         if ($ruleId) {
             $this->load();
         } else {
@@ -44,13 +44,13 @@ class LockedAdmission extends AdmissionRule
     public function delete() {
         parent::delete();
         // Delete rule data.
-        $stmt = DBManager::get()->prepare("DELETE FROM `lockedadmissions` 
+        $stmt = DBManager::get()->prepare("DELETE FROM `lockedadmissions`
             WHERE `rule_id`=?");
         $stmt->execute([$this->id]);
     }
 
     /**
-     * Gets some text that describes what this AdmissionRule (or respective 
+     * Gets some text that describes what this AdmissionRule (or respective
      * subclass) does.
      */
     public static function getDescription() {
@@ -68,12 +68,12 @@ class LockedAdmission extends AdmissionRule
 
     /**
      * Gets the template that provides a configuration GUI for this rule.
-     * 
+     *
      * @return String
      */
     public function getTemplate() {
         $factory = new Flexi\Factory(dirname(__FILE__).'/templates/');
-        // Now open specific template for this rule and insert base template. 
+        // Now open specific template for this rule and insert base template.
         $tpl = $factory->open('configure');
         $tpl->set_attribute('rule', $this);
         return $tpl->render();
@@ -86,13 +86,16 @@ class LockedAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT * FROM `lockedadmissions`
             WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
+        } else {
+            $this->id = $this->generateId('lockedadmissions');
         }
     }
 
     /**
-     * Does the current rule allow the given user to register as participant 
+     * Does the current rule allow the given user to register as participant
      * in the given course? Never happens here as admission is completely
      * locked.
      *
diff --git a/lib/admissionrules/lockedadmission/templates/configure.php b/lib/admissionrules/lockedadmission/templates/configure.php
index 4a60973d09b..5be3f66002c 100644
--- a/lib/admissionrules/lockedadmission/templates/configure.php
+++ b/lib/admissionrules/lockedadmission/templates/configure.php
@@ -1,5 +1,3 @@
-<h3><?= $rule->getName() ?></h3>
-<label for="message" class="caption">
-    <?= _('Nachricht bei fehlgeschlagener Anmeldung') ?>:
-</label>
-<textarea name="message" rows="4" cols="50"><?= $rule->getMessage() ?></textarea>
\ No newline at end of file
+<div data-admission-rule="LockedAdmission">
+    <locked-admission></locked-admission>
+</div>
diff --git a/lib/admissionrules/participantrestrictedadmission/ParticipantRestrictedAdmission.php b/lib/admissionrules/participantrestrictedadmission/ParticipantRestrictedAdmission.php
index 586f82f6ef7..2954602258f 100644
--- a/lib/admissionrules/participantrestrictedadmission/ParticipantRestrictedAdmission.php
+++ b/lib/admissionrules/participantrestrictedadmission/ParticipantRestrictedAdmission.php
@@ -51,7 +51,7 @@ class ParticipantRestrictedAdmission extends AdmissionRule
         }
     }
 
-    public function isFCFSallowed()
+    public function isFCFSAllowed()
     {
         return $this->first_come_first_served_allowed;
     }
@@ -123,12 +123,15 @@ class ParticipantRestrictedAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `participantrestrictedadmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->distributionTime = $current['distribution_time'];
             if ($current['distribution_time'] > 0) {
                 $this->prio_exists = DBManager::get()->fetchColumn("SELECT 1 FROM courseset_rule INNER JOIN priorities USING(set_id) WHERE rule_id = ? LIMIT 1", [$this->id]);
             }
+        } else {
+            $this->id = $this->generateId('participantrestrictedadmissions');
         }
     }
 
@@ -143,14 +146,11 @@ class ParticipantRestrictedAdmission extends AdmissionRule
     public function setAllData($data)
     {
         parent::setAllData($data);
-        if (!empty($data['distributiondate'])) {
-            if (!$data['distributiontime']) {
-                $data['distributiontime'] = '23:59';
-            }
-            $ddate = strtotime($data['distributiondate'] . ' ' . $data['distributiontime']);
-            $this->setDistributionTime($ddate);
+
+        if (!empty($data['distribution-time'])) {
+            $this->setDistributionTime($data['distribution-time']);
         }
-        if (!empty($data['enable_FCFS'])) {
+        if (!empty($data['fcfs'])) {
             $this->setDistributionTime(0);
         }
         if (!empty($data['startdate'])) {
@@ -227,4 +227,19 @@ class ParticipantRestrictedAdmission extends AdmissionRule
         }
         return $errors;
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'distribution-time' => $this->getDistributionTime(),
+                'fcfs-allowed' => $this->isFCFSAllowed()
+            ]
+        );
+    }
+
 }
diff --git a/lib/admissionrules/participantrestrictedadmission/templates/configure.php b/lib/admissionrules/participantrestrictedadmission/templates/configure.php
index 3c02c6c65da..c2b50f3731e 100644
--- a/lib/admissionrules/participantrestrictedadmission/templates/configure.php
+++ b/lib/admissionrules/participantrestrictedadmission/templates/configure.php
@@ -1,30 +1,5 @@
-<h3><?= $rule->getName() ?></h3>
-<label for="start" class="caption">
-    <?= _('Zeitpunkt der automatischen Platzverteilung') ?>:
-</label>
-
-<label class="col-3">
-    <?= _('Datum') ?>
-    <input type="text" name="distributiondate" id="distributiondate"
-        class="size-s no-hint" placeholder="tt.mm.jjjj"
-        value="<?= $rule->getDistributionTime() ? date('d.m.Y', $rule->getDistributionTime()) : '' ?>"/>
-</label>
-
-<label class="col-3">
-    <?= _('Uhrzeit') ?>
-    <input type="text" name="distributiontime" id="distributiontime"
-        class="size-s no-hint" placeholder="ss:mm"
-        value="<?= $rule->getDistributionTime() ? date('H:i', $rule->getDistributionTime()) : '23:59' ?>"/>
-</label>
-
-<? if ($rule->isFCFSallowed()) : ?>
-    <label for="enable_FCFS">
-    <input <?= !empty($rule->prio_exists ? 'disabled' : '') ?> type="checkbox" id="enable_FCFS"  name="enable_FCFS" value="1" <?= (!is_null($rule->getDistributionTime()) && !$rule->getDistributionTime() ? "checked" : ""); ?>>
-    <?=_("<u>Keine</u> automatische Platzverteilung (Windhund-Verfahren)")?>
-    <?= !empty($rule->prio_exists) ? tooltipicon(_("Es existieren bereits Anmeldungen für die automatische Platzverteilung.")) : '' ?>
-    </label>
-<? endif ?>
-<script>
-    $('#distributiondate').datepicker();
-    $('#distributiontime').timepicker();
-</script>
+<div data-admission-rule="ParticipantRestrictedAdmission">
+    <participant-restricted-admission :distribution="<?= $rule->getDistributionTime() ?>"
+                                      :fcfs="<?= $rule->isFCFSAllowed() ? 'true' : 'false'?>"
+                                      :hasPrios="false"></participant-restricted-admission>
+</div>
diff --git a/lib/admissionrules/participantrestrictedadmission/templates/info.php b/lib/admissionrules/participantrestrictedadmission/templates/info.php
index 886ee58a14d..e89a5740f61 100644
--- a/lib/admissionrules/participantrestrictedadmission/templates/info.php
+++ b/lib/admissionrules/participantrestrictedadmission/templates/info.php
@@ -3,13 +3,13 @@
 <? if ($rule->getDistributionTime()) : ?>
     <? if ($rule->getDistributionTime() > time()) : ?>
     <?= sprintf(_('Die Plätze in den betreffenden Veranstaltungen werden am %s '.
-    'um %s verteilt.'), date("d.m.Y", $rule->getDistributionTime()), 
+    'um %s verteilt.'), date("d.m.Y", $rule->getDistributionTime()),
     date("H:i", $rule->getDistributionTime())) ?>
     <? else : ?>
     <?= sprintf(_('Die Plätze in den betreffenden Veranstaltungen wurden am %s '.
-    'um %s verteilt. Weitere Plätze werden evtl. über Wartelisten zur Verfügung gestellt.'), date("d.m.Y", $rule->getDistributionTime()), 
+    'um %s verteilt. Weitere Plätze werden evtl. über Wartelisten zur Verfügung gestellt.'), date("d.m.Y", $rule->getDistributionTime()),
     date("H:i", $rule->getDistributionTime())) ?>
     <? endif ?>
-<? elseif ($rule->isFCFSallowed()) :?>
+<? elseif ($rule->isFCFSAllowed()) :?>
     <?= _("Die Plätze werden in der Reihenfolge der Anmeldung vergeben.")?>
-<? endif ?>
\ No newline at end of file
+<? endif ?>
diff --git a/lib/admissionrules/passwordadmission/PasswordAdmission.php b/lib/admissionrules/passwordadmission/PasswordAdmission.php
index 871502113d3..85c6e2ddd5b 100644
--- a/lib/admissionrules/passwordadmission/PasswordAdmission.php
+++ b/lib/admissionrules/passwordadmission/PasswordAdmission.php
@@ -126,11 +126,14 @@ class PasswordAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT * FROM `passwordadmissions`
             WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->startTime = $current['start_time'];
             $this->endTime = $current['end_time'];
             $this->password = $current['password'];
+        } else {
+            $this->id = $this->generateId('passwordadmissions');
         }
     }
 
@@ -238,4 +241,18 @@ class PasswordAdmission extends AdmissionRule
         }
         return $errors;
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'password' => $this->getPassword()
+            ]
+        );
+    }
+
 }
diff --git a/lib/admissionrules/passwordadmission/templates/configure.php b/lib/admissionrules/passwordadmission/templates/configure.php
index 4642dec2805..5a95b8513c8 100644
--- a/lib/admissionrules/passwordadmission/templates/configure.php
+++ b/lib/admissionrules/passwordadmission/templates/configure.php
@@ -1,15 +1,3 @@
-<h3><?= htmlReady($rule->getName()) ?></h3>
-<label>
-    <?= _('Nachricht bei fehlgeschlagener Anmeldung') ?>:
-    <textarea name="message" rows="4" cols="50"><?= htmlReady($rule->getMessage()) ?></textarea>
-</label>
-<label>
-    <?= _('Zugangspasswort') ?>:
-    <input type="password" name="password1" size="25" max="40"
-       value="<?= htmlReady(Request::get('password1')) ?>" <?= $rule->new ? 'required' : ''?>>
-</label>
-<label>
-    <?= _('Passwort wiederholen') ?>:
-    <input type="password" name="password2" size="25" max="40"
-           value="<?= htmlReady(Request::get('password2')) ?>" <?= $rule->new ? 'required' : ''?>>
-</label>
+<div data-admission-rule="PasswordAdmission">
+    <password-admission></password-admission>
+</div>
diff --git a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
index 2f57a446622..23633d6d62a 100644
--- a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
+++ b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
@@ -352,7 +352,8 @@ class PreferentialAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `prefadmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->favorSemester = $current['favor_semester'];
             // Retrieve conditions.
             $stmt = DBManager::get()->prepare("SELECT *
@@ -363,6 +364,8 @@ class PreferentialAdmission extends AdmissionRule
                 $currentCondition = new UserFilter($condition['condition_id']);
                 $this->conditions[$condition['condition_id']] = $currentCondition;
             }
+        } else {
+            $this->id = $this->generateId('prefadmissions');
         }
     }
 
@@ -402,13 +405,20 @@ class PreferentialAdmission extends AdmissionRule
      */
     public function setAllData($data)
     {
-        UserFilterField::getAvailableFilterFields();
         parent::setAllData($data);
-        $this->favorSemester = (bool) $data['favor_semester'];
+        $this->favorSemester = (bool) $data['favor-semester'];
         $this->conditions = [];
         if ($data['conditions']) {
-            foreach ($data['conditions'] as $condition) {
-                $this->addCondition(ObjectBuilder::build($condition, 'UserFilter'));
+            foreach ($data['conditions'] as $con) {
+                $condition = new UserFilter();
+                foreach ($con['attributes']['fields'] as $field) {
+                    $classname = $field['attributes']['type'];
+                    $obj = new $classname();
+                    $obj->setCompareOperator($field['attributes']['compare-operator']);
+                    $obj->setValue($field['attributes']['value']);
+                    $condition->addField($obj);
+                }
+                $this->addCondition($condition);
             }
         }
         return $this;
@@ -516,7 +526,7 @@ class PreferentialAdmission extends AdmissionRule
     public function validate($data)
     {
         $errors = parent::validate($data);
-        if (!$data['conditions'] && !$data['favor_semester']) {
+        if (!$data['conditions'] && !$data['favor-semester']) {
             $errors[] = _('Es muss mindestens eine Auswahlbedingung angegeben werden.');
         }
         return $errors;
@@ -534,4 +544,41 @@ class PreferentialAdmission extends AdmissionRule
         $this->conditions = $cloned_conditions;
     }
 
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        // Build everything as plain array.
+        $conditions = [];
+        foreach ($this->getConditions() as $one) {
+            $fields = [];
+            foreach ($one->getFields() as $field) {
+                $fields[] = [
+                    'attributes' => [
+                        'type' => get_class($field),
+                        'id' => $field->getId(),
+                        'compare-operator' => $field->getCompareOperator(),
+                        'value' => $field->getValue()
+                    ]
+                ];
+            }
+
+            $conditions[] = [
+                'attributes' => [
+                    'text' => $one->toString(),
+                    'fields' => $fields
+                ]
+            ];
+        }
+
+        return array_merge(
+            parent::getPayload(),
+            [
+                'conditions' => $conditions,
+                'favor-semester' => $this->getFavorSemester()
+            ]
+        );
+    }
+
 } /* end of class PreferentialAdmission */
diff --git a/lib/admissionrules/preferentialadmission/templates/configure.php b/lib/admissionrules/preferentialadmission/templates/configure.php
index 1568caffb9c..240e12c4b21 100644
--- a/lib/admissionrules/preferentialadmission/templates/configure.php
+++ b/lib/admissionrules/preferentialadmission/templates/configure.php
@@ -1,38 +1,3 @@
-<h3><?= htmlReady($rule->getName()) ?></h3>
-<label for="prefadmission_conditions" class="caption">
-    <?= _('Folgende Personen bei der Platzverteilung bevorzugen:') ?>
-</label>
-<div id="prefadmission_conditions">
-    <span class="nofilter" style="<?=(!$rule->getConditions() ? '' : 'display: none')?>">
-        <i><?= _('Sie haben noch keine Auswahl festgelegt.'); ?></i>
-    </span>
-    <div class="userfilter" style="<?=($rule->getConditions() ? '' : 'display: none')?>">
-        <div id="no_conditiongroups" class="ungrouped_conditions">
-            <div class="condition_list">
-                <?php foreach ($rule->getConditions() as $condition) :
-                    $condition->show_user_count = true; ?>
-
-                    <div class="condition" id="condition_<?= $condition->getId() ?>">
-                        <?= $condition->toString() ?>
-                        <a href="#" onclick="return STUDIP.UserFilter.removeConditionField($(this).parent())"
-                            class="conditionfield_delete">
-                            <?= Icon::create('trash', 'clickable')->asImg(); ?></a>
-                        <input type="hidden" name="conditions[]" value="<?= htmlReady(ObjectBuilder::exportAsJson($condition)) ?>"/>
-                    </div>
-                <?php endforeach ?>
-            </div>
-        </div>
-    </div>
-    <br><br>
-    <a href="<?= URLHelper::getURL('dispatch.php/userfilter/filter/configure/prefadmission_conditions') ?>"
-       onclick="return STUDIP.UserFilter.configureCondition('condition', '<?=
-            URLHelper::getLink('dispatch.php/userfilter/filter/configure/prefadmission_conditions') ?>')">
-        <?= Icon::create('add')->asImg(['title' => _('Bedingung hinzufügen'), 'alt' => _('Bedingung hinzufügen')]) ?>
-        <?= _('Bedingung hinzufügen') ?>
-    </a>
+<div data-admission-rule="PreferentialAdmission">
+    <preferential-admission></preferential-admission>
 </div>
-<br>
-<label class="caption">
-    <input type="checkbox" name="favor_semester"<?= $rule->getFavorSemester() ? ' checked' : '' ?>/>
-    <?= _('Höhere Fachsemester bevorzugen') ?>
-</label>
diff --git a/lib/admissionrules/termsadmission/TermsAdmission.php b/lib/admissionrules/termsadmission/TermsAdmission.php
index eb83dfcc2d0..5e98513abda 100644
--- a/lib/admissionrules/termsadmission/TermsAdmission.php
+++ b/lib/admissionrules/termsadmission/TermsAdmission.php
@@ -137,7 +137,12 @@ class TermsAdmission extends AdmissionRule
     public function load()
     {
         $rule = DBManager::get()->fetchOne('SELECT * FROM termsadmissions WHERE rule_id = ?', [$this->getId()]);
-        $this->terms = $rule['terms'];
+
+        if ($rule) {
+            $this->terms = $rule['terms'];
+        } else {
+            $this->id = $this->generateId('termsadmissions');
+        }
         return $this;
     }
 
@@ -166,4 +171,18 @@ class TermsAdmission extends AdmissionRule
 
         return $template->render();
     }
+
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'terms' => $this->terms
+            ]
+        );
+    }
+
 }
diff --git a/lib/admissionrules/termsadmission/templates/configure.php b/lib/admissionrules/termsadmission/templates/configure.php
index 90343193548..48d4dfddc56 100644
--- a/lib/admissionrules/termsadmission/templates/configure.php
+++ b/lib/admissionrules/termsadmission/templates/configure.php
@@ -1,4 +1,3 @@
-<label>
-    <span class="required"><?= _('Teilnahmebedingungen') ?></span>
-    <textarea style="min-height: 24em; min-width: 44em;" name="terms" placeholder="<?=_('Formulieren Sie hier die Teilnahmebedingungen.')?>" required><?= htmlReady($rule->terms) ?></textarea>
-</label>
+<div data-admission-rule="TermsAdmission">
+    <terms-admission terms="<?= htmlReady($rule->terms) ?>" id="<?= htmlReady($rule->getId()) ?>"></terms-admission>
+</div>
diff --git a/lib/admissionrules/timedadmission/TimedAdmission.php b/lib/admissionrules/timedadmission/TimedAdmission.php
index 5f5e88570c4..69a828e7cb4 100644
--- a/lib/admissionrules/timedadmission/TimedAdmission.php
+++ b/lib/admissionrules/timedadmission/TimedAdmission.php
@@ -116,10 +116,13 @@ class TimedAdmission extends AdmissionRule
         $stmt = DBManager::get()->prepare("SELECT *
             FROM `timedadmissions` WHERE `rule_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        $current = $stmt->fetchOne();
+        if ($current) {
             $this->message = $current['message'];
             $this->startTime = $current['start_time'];
             $this->endTime = $current['end_time'];
+        } else {
+            $this->id = $this->generateId('timedadmissions');
         }
     }
 
@@ -148,22 +151,11 @@ class TimedAdmission extends AdmissionRule
      */
     public function setAllData($data) {
         parent::setAllData($data);
-        if ($data['startdate']) {
-            $sdate = $data['startdate'];
-            $stime = $data['starttime'];
-            $parsed = date_parse($sdate.' '.$stime);
-            $timestamp = mktime($parsed['hour'], $parsed['minute'], 0, $parsed['month'], $parsed['day'], $parsed['year']);
-            $this->setStartTime($timestamp);
+        if ($data['starttime']) {
+            $this->setStartTime($data['starttime']);
         }
-        if ($data['enddate']) {
-            $edate = $data['enddate'];
-            $etime = $data['endtime'];
-            if (!$etime) {
-                $etime = '23:59';
-            }
-            $parsed = date_parse($edate.' '.$etime);
-            $timestamp = mktime($parsed['hour'], $parsed['minute'], 0, $parsed['month'], $parsed['day'], $parsed['year']);
-            $this->setEndTime($timestamp);
+        if ($data['endtime']) {
+            $this->setEndTime($data['endtime']);
         }
         return $this;
     }
@@ -229,15 +221,29 @@ class TimedAdmission extends AdmissionRule
     public function validate($data)
     {
         $errors = parent::validate($data);
-        if (!$data['startdate'] && !$data['enddate']) {
+        if (!$data['starttime'] && !$data['endtime']) {
             $errors[] = _('Bitte geben Sie entweder ein Start- oder Enddatum an.');
         }
-        if ($data['startdate'] && $data['enddate'] && strtotime($data['enddate'] . ' ' . $data['endtime']) < strtotime($data['startdate']. ' ' . $data['starttime'])) {
+        if ($data['starttime'] && $data['endtime'] && $data['endtime'] < $data['starttime']) {
             $errors[] = _('Das Enddatum darf nicht vor dem Startdatum liegen.');
         }
         return $errors;
     }
 
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return array_merge(
+            parent::getPayload(),
+            [
+                'starttime' => $this->getStartTime(),
+                'endtime' => $this->getEndTime()
+            ]
+        );
+    }
+
 } /* end of class TimedAdmission */
 
 ?>
diff --git a/lib/admissionrules/timedadmission/templates/configure.php b/lib/admissionrules/timedadmission/templates/configure.php
index 6ea65e6bbba..d39f6ab4654 100644
--- a/lib/admissionrules/timedadmission/templates/configure.php
+++ b/lib/admissionrules/timedadmission/templates/configure.php
@@ -1,47 +1,3 @@
-<h3><?= $rule->getName() ?></h3>
-<label for="message" class="caption">
-    <?= _('Nachricht bei fehlgeschlagener Anmeldung') ?>:
-    <textarea name="message" rows="4" cols="50"><?= $rule->getMessage() ?></textarea>
-</label>
-
-<label for="startdate" class="caption">
-    <?= _('Start des Anmeldezeitraums') ?>:
-</label>
-<label class="col-3">
-    <?= _('Datum') ?>
-    <input type="text" maxlength="10" name="startdate"
-        class="size-s no-hint" placeholder="tt.mm.jjjj"
-        id="startdate" value="<?= $rule->getStartTime() ?
-        date('d.m.Y', $rule->getStartTime()) : '' ?>" data-max-date=""/>
-</label>
-<label class="col-3">
-    <?= _('Uhrzeit') ?>
-    <input type="text" name="starttime" id="starttime"
-        class="size-s no-hint" placeholder="ss:mm"
-        value="<?= $rule->getStartTime() ? date('H:i', $rule->getStartTime()) : '' ?>"/>
-</label>
-
-<label for="enddate" class="caption">
-    <?= _('Ende des Anmeldezeitraums') ?>:
-</label>
-
-<label class="col-3">
-    <?= _('Datum') ?>
-    <input type="text" maxlength="10" name="enddate"
-        class="size-s no-hint" placeholder="tt.mm.jjjj"
-        id="enddate" value="<?= $rule->getEndTime() ?
-        date('d.m.Y', $rule->getEndTime()) : '' ?>" data-min-date=""/>
-</label>
-<label class="col-3">
-    <?= _('Uhrzeit') ?>
-    <input type="text" name="endtime" id="endtime"
-        class="size-s no-hint" placeholder="ss:mm"
-        value="<?= $rule->getEndTime() ? date('H:i', $rule->getEndTime()) : '' ?>"/>
-</label>
-
-<script>
-    $('#startdate').datepicker();
-    $('#starttime').timepicker();
-    $('#enddate').datepicker();
-    $('#endtime').timepicker();
-</script>
+<div id="admission-rule" data-admission-rule="TimedAdmission">
+    <timed-admission :start="<?= $startTime ?: time() ?>" :end="<?= $endTime ?: (time() + 3600) ?>"></timed-admission>
+</div>
diff --git a/lib/classes/CoursesetModel.php b/lib/classes/CoursesetModel.php
index 076355d9c07..6a0e6919f0e 100644
--- a/lib/classes/CoursesetModel.php
+++ b/lib/classes/CoursesetModel.php
@@ -48,7 +48,7 @@ class CoursesetModel
                       WHERE s.status NOT IN(?)
                         AND (semester_courses.semester_id IS NULL OR semester_courses.semester_id = ?)
                         AND su.`user_id` = ?
-                      GROUP BY su.`Seminar_id`  ";
+                      GROUP BY su.`Seminar_id`";
             $parameters = [
                 $excludeTypes,
                 $currentSemester->id,
@@ -148,7 +148,7 @@ class CoursesetModel
             );
 
         };
-        Course::findEachMany($callable, array_unique($courses),"ORDER BY `semester_data`.`beginn` DESC, `VeranstaltungsNummer` ASC, `Name` ASC");
+        Course::findEachMany($callable, array_unique($courses),"ORDER BY `VeranstaltungsNummer` ASC, `Name` ASC");
 
         return $data;
     }
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 8c6037ac990..b13887f3af3 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -115,6 +115,7 @@ class RouteMap
 
         $group->get('/status-groups/{id}', Routes\StatusgroupShow::class);
 
+        $this->addAuthenticatedAdmissionRoutes($group);
         $this->addAuthenticatedBlubberRoutes($group);
         $this->addAuthenticatedClipboardRoutes($group);
         $this->addAuthenticatedConsultationRoutes($group);
@@ -136,6 +137,7 @@ class RouteMap
         $this->addAuthenticatedNewsRoutes($group);
         $this->addAuthenticatedStockImagesRoutes($group);
         $this->addAuthenticatedStudyAreasRoutes($group);
+        $this->addAuthenticatedUserFilterRoutes($group);
         $this->addAuthenticatedWikiRoutes($group);
     }
 
@@ -168,6 +170,20 @@ class RouteMap
         return $this->app->getContainer()->get('studip-authenticator');
     }
 
+    private function addAuthenticatedAdmissionRoutes(RouteCollectorProxy $group): void {
+        $group->post('/course-sets', Routes\Admission\CourseSetsCreate::class);
+        $group->get('/course-sets/{id}', Routes\Admission\CourseSetsShow::class);
+        $group->patch('/course-sets/{id}', Routes\Admission\CourseSetsUpdate::class);
+        $group->delete('/course-sets/{id}', Routes\Admission\CourseSetsDelete::class);
+        $group->post('/admission/available-courses', Routes\Admission\AvailableCoursesIndex::class);
+        $group->get('/admission/rule-compatibility', Routes\Admission\RuleCompatibilityIndex::class);
+        $group->get('/admission-rules', Routes\Admission\AdmissionRulesIndex::class);
+        $group->post('/admission-rules/{type}', Routes\Admission\AdmissionRulesCreate::class);
+        $group->get('/admission-rules/{id}', Routes\Admission\AdmissionRulesShow::class);
+        $group->patch('/admission-rules/{id}', Routes\Admission\AdmissionRulesUpdate::class);
+        $group->delete('/admission-rules/{id}', Routes\Admission\AdmissionRulesDelete::class);
+    }
+
     private function addAuthenticatedBlubberRoutes(RouteCollectorProxy $group): void
     {
         // find BlubberThreads
@@ -660,6 +676,16 @@ class RouteMap
         $group->post('/{type:courses|institutes|users}/{id}/avatar', Routes\Avatar\AvatarUpload::class);
     }
 
+    private function addAuthenticatedUserFilterRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/user-filters/{id}', Routes\UserFilters\UserFiltersShow::class);
+        $group->post('/user-filters', Routes\UserFilters\UserFiltersCreate::class);
+        $group->patch('/user-filters/{id}', Routes\UserFilters\UserFiltersUpdate::class);
+        $group->delete('/user-filters/{id}', Routes\UserFilters\UserFiltersDelete::class);
+        $group->get('/user-filter-fields', Routes\UserFilters\UserFilterFieldsIndex::class);
+        $group->get('/user-filter-fields/{id}', Routes\UserFilters\UserFilterFieldsShow::class);
+    }
+
     private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void
     {
         $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler);
diff --git a/lib/classes/JsonApi/Routes/Admission/AdmissionRulesCreate.php b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesCreate.php
new file mode 100644
index 00000000000..2c8c6f2ab38
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesCreate.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Create a new admission rule.
+ */
+class AdmissionRulesCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditAdmissionRules($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $rule = \AdmissionRule::getRule($args['type']);
+        $rule->setAllData(self::arrayGet($json, 'data.attributes.payload'));
+        $rule->id = '';
+
+        return $this->getCreatedResponse($rule);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.payload')) {
+            return 'Missing `payload` member of attributes block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/AdmissionRulesDelete.php b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesDelete.php
new file mode 100644
index 00000000000..16102d72e61
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Deletes an admission rule.
+ */
+class AdmissionRulesDelete extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditAdmissionRules($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        [$type, $id] = explode('_', $args['id']);
+
+        $rule = \AdmissionRule::getRule($type, $id);
+        if (!$rule) {
+            throw new RecordNotFoundException();
+        }
+
+        $rule->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/AdmissionRulesIndex.php b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesIndex.php
new file mode 100644
index 00000000000..6d1fb305a1a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesIndex.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class AdmissionRulesIndex extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $rules = [];
+        foreach (array_keys(\AdmissionRule::getAvailableAdmissionRules()) as $class) {
+            $rules[] = new $class();
+        }
+
+        return $this->getContentResponse($rules);
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/AdmissionRulesShow.php b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesShow.php
new file mode 100644
index 00000000000..a0eb8fe5571
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesShow.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Shows a single admission rule.
+ */
+class AdmissionRulesShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $chunks = explode('_', $args['id']);
+        $classname = $chunks[0];
+        $id = $chunks[1] ?? null;
+
+        $rule = \AdmissionRule::getRule($classname, $id);
+        if (!$rule) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($rule);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/AdmissionRulesUpdate.php b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesUpdate.php
new file mode 100644
index 00000000000..5e4fefd86d2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AdmissionRulesUpdate.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Update an admission rule.
+ */
+class AdmissionRulesUpdate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditAdmissionRules($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        [$type, $id] = explode('_', $args['id']);
+
+        $rule = \AdmissionRule::getRule($type, $id);
+        if (!$rule) {
+            throw new RecordNotFoundException();
+        }
+
+        $payload = self::arrayGet($json, 'data.attributes.payload');
+
+        $rule->setAllData($payload);
+
+        $rule->store();
+
+        return $this->getContentResponse($rule);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.payload')) {
+            return 'Missing `payload` member of attributes block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/Authority.php b/lib/classes/JsonApi/Routes/Admission/Authority.php
new file mode 100644
index 00000000000..64df1d93b9d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/Authority.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use Config;
+use CourseSet;
+use Institute;
+use User;
+
+class Authority
+{
+
+    /**
+     * Checks if the given user may create a courseset. As this is provided as "quick action" inside of courses,
+     * dozent permissions are sufficient.
+     * @param User $user
+     * @return bool
+     */
+    public static function canCreateCourseSet(User $user): bool
+    {
+        return $GLOBALS['perm']->have_perm('dozent');
+    }
+
+    public static function canCreateCourseSets(User $user): bool
+    {
+        return $GLOBALS['perm']->have_perm('admin', $user->id)
+            || (
+                Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
+                && $GLOBALS['perm']->have_perm('dozent', $user->id)
+            );
+    }
+
+    public static function canEditAdmissionRules(User $user): bool
+    {
+        return $GLOBALS['perm']->have_perm('admin', $user->id)
+            || (
+                Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
+                && $GLOBALS['perm']->have_perm('dozent', $user->id)
+            );
+    }
+
+    /**
+     * Checks if the given user may update the given courseset.
+     *
+     * @param User $user
+     * @param CourseSet $courseset
+     * @return bool
+     */
+    public static function canUpdateCourseSet(User $user, CourseSet $courseset)
+    {
+        if ($GLOBALS['perm']->have_perm('root') || $courseset->getUserId() === $user->id) {
+            return true;
+        } else {
+            $institutes = array_map(
+                fn ($i) => $i['Institut_id'],
+                Institute::getMyInstitutes($user->id)
+            );
+
+            // Check access for admin accounts.
+            $access = $GLOBALS['perm']->have_perm('admin')
+                && array_intersect($courseset->getInstituteIds(), $institutes);
+
+            if (!$access) {
+
+                // Check access for lecturers if the config option is set.
+                $access = Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
+                    && $GLOBALS['perm']->have_perm('dozent')
+                    && array_intersect($courseset->getInstituteIds(), $institutes);
+            }
+
+            return $access;
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/AvailableCoursesIndex.php b/lib/classes/JsonApi/Routes/Admission/AvailableCoursesIndex.php
new file mode 100644
index 00000000000..cb7a6456d80
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/AvailableCoursesIndex.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Zeigt alle Veranstaltungen an, die keinem Anmeldeset zugeordnet sind.
+ */
+class AvailableCoursesIndex extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $body = $request->getParsedBody();
+
+        $semester = \Semester::find($body['semester']);
+
+        if (!$semester) {
+            throw new RecordNotFoundException();
+        }
+
+        $courses = \CoursesetModel::getInstCourses(
+            $body['institutes'],
+            $body['courseset'],
+            $body['exclude'],
+            $semester->id,
+            $body['filter']
+        );
+
+        $courses = count($courses) > 0 ? \Course::findMany(array_keys($courses)) : [];
+
+        return $this->getContentResponse($courses);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/CourseSetsCreate.php b/lib/classes/JsonApi/Routes/Admission/CourseSetsCreate.php
new file mode 100644
index 00000000000..a6c46deaa24
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/CourseSetsCreate.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Create a new courseset.
+ */
+class CourseSetsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!Authority::canCreateCourseSet($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        $json = $this->validate($request);
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreateCourseSets($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $cs = new \CourseSet();
+        $cs->setName(self::arrayGet($json, 'data.attributes.name'));
+
+        foreach (self::arrayGet($json, 'data.attributes.rules') as $oneRule) {
+            $classname = '\\' . $oneRule['attributes']['type'];
+            $rule = new $classname();
+            $rule->setAllData($oneRule['attributes']['payload']);
+            $cs->addAdmissionRule($rule);
+        }
+
+        $cs->setPrivate(self::arrayGet($json, 'data.attributes.private'));
+        $cs->setAlgorithm('RandomAlgorithm');
+        $cs->setInstitutes(self::arrayGet($json, 'data.attributes.institutes'));
+        $cs->setCourses(self::arrayGet($json, 'data.attributes.courses'));
+        $cs->setUserlists(self::arrayGet($json, 'data.attributes.userlists'));
+
+        $cs->store();
+
+        return $this->getCreatedResponse($cs);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.name')) {
+            return 'Missing `name` member of data block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/CourseSetsDelete.php b/lib/classes/JsonApi/Routes/Admission/CourseSetsDelete.php
new file mode 100644
index 00000000000..5d241d037f7
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/CourseSetsDelete.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Deletes a courseset
+ */
+class CourseSetsDelete extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+
+        $cs = new \CourseSet($args['id']);
+        if (!$cs->getChdate()) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canUpdateCourseSet($user, $cs)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $cs->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/CourseSetsShow.php b/lib/classes/JsonApi/Routes/Admission/CourseSetsShow.php
new file mode 100644
index 00000000000..035a4066745
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/CourseSetsShow.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Shows a single courseset.
+ */
+class CourseSetsShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'admission-rules',
+        'institutes',
+        'courses',
+        'semester',
+        'owner'
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $courseset = new \CourseSet($args['id']);
+        if (!$courseset) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($courseset);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/CourseSetsUpdate.php b/lib/classes/JsonApi/Routes/Admission/CourseSetsUpdate.php
new file mode 100644
index 00000000000..0b26f6c2b19
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/CourseSetsUpdate.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace JsonApi\Routes\Admission;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Updates an existing courseset.
+ */
+class CourseSetsUpdate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $cs = new \CourseSet($args['id']);
+
+        if (!$cs->getChdate()) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canUpdateCourseSet($this->getUser($request), $cs)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $json = $this->validate($request);
+
+        $cs->setName(self::arrayGet($json, 'data.attributes.name'));
+        $cs->clearAdmissionRules();
+
+        foreach (self::arrayGet($json, 'data.attributes.rules') as $oneRule) {
+            [$classname, $id] = explode('_', $oneRule['id']);
+            $classname = '\\' . $classname;
+
+            $rule = new $classname($id);
+            $rule->setAllData($oneRule['attributes']['payload']);
+            $cs->addAdmissionRule($rule);
+        }
+
+        $cs->setPrivate(self::arrayGet($json, 'data.attributes.private'));
+        $cs->setInfoText(self::arrayGet($json, 'data.attributes.infotext'));
+        $cs->setAlgorithm('RandomAlgorithm');
+        $cs->setInstitutes(self::arrayGet($json, 'data.attributes.institutes'));
+        $cs->setCourses(self::arrayGet($json, 'data.attributes.courses'));
+        $cs->setUserlists(self::arrayGet($json, 'data.attributes.userlists'));
+
+        $cs->store();
+
+        return $this->getCreatedResponse($cs);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.name')) {
+            return 'Missing `name` member of data block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/Admission/RuleCompatibilityIndex.php b/lib/classes/JsonApi/Routes/Admission/RuleCompatibilityIndex.php
new file mode 100644
index 00000000000..d1d9f334dd8
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Admission/RuleCompatibilityIndex.php
@@ -0,0 +1,20 @@
+<?php
+namespace JsonApi\Routes\Admission;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\NonJsonApiController;
+
+class RuleCompatibilityIndex extends NonJsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $response->getBody()->write(json_encode(\AdmissionRuleCompatibility::getCompatibilityMatrix()));
+
+        return $response->withHeader('Content-type', 'application/json');
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/Authority.php b/lib/classes/JsonApi/Routes/UserFilters/Authority.php
new file mode 100644
index 00000000000..d934894bd5e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/Authority.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use Config;
+use User;
+
+class Authority
+{
+    public static function canEditUserFilters(User $user): bool
+    {
+        return $GLOBALS['perm']->have_perm('admin', $user->id)
+            || (
+                Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
+                && $GLOBALS['perm']->have_perm('dozent', $user->id)
+            );
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
new file mode 100644
index 00000000000..ede43cf4397
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class UserFilterFieldsIndex extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $fields = [];
+        foreach (\UserFilterField::getAvailableFilterFields() as $class => $name) {
+            // Generic datafield conditions must be handled differently.
+            if (str_contains($class, '_')) {
+                [$classname, $typeparam] = explode('_', $class);
+                $fields[] = new $classname($typeparam);
+            } else {
+                $fields[] = new $class();
+            }
+        }
+
+        return $this->getContentResponse($fields);
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsShow.php b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsShow.php
new file mode 100644
index 00000000000..94afbe30d6e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsShow.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Shows a single UserFilterField.
+ */
+class UserFilterFieldsShow extends JsonApiController
+{
+    protected $allowedIncludePaths = ['users'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        [$class, $id] = explode('_', $args['id']);
+
+        $classname = '\\' . $class;
+
+        $field = new $classname($id);
+
+        // The userfilter object has a new ID -> new object not yet existing in database.
+        if ($field->getId() !== $id) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($field);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
new file mode 100644
index 00000000000..42cd58363ba
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Create a new UserFilter.
+ */
+class UserFiltersCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $json = $this->validate($request);
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditUserFilters($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $filter = new \UserFilter();
+        $filter->show_user_count = true;
+
+        foreach (self::arrayGet($json, 'data.attributes.filters') as $one) {
+            $classname = '\\' . $one['attributes']['type'];
+            $field = !empty($one['attributes']['typeparam'])
+                ? new $classname($one['attributes']['typeparam'])
+                : new $classname();
+            $field->setValue($one['attributes']['value']);
+            $field->setCompareOperator($one['attributes']['compare-operator']);
+            $filter->addField($field);
+        }
+
+        $filter->id = '';
+
+        return $this->getCreatedResponse($filter);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.filters')) {
+            return 'Missing `filters` member of attributes block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
new file mode 100644
index 00000000000..6f2b0cb3451
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Deletes a user filter
+ */
+class UserFiltersDelete extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditUserFilters($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $filter = new \UserFilter($args['id']);
+
+        if ($filter['id'] !== $args['id']) {
+            throw new RecordNotFoundException();
+        }
+
+        $filter->delete();
+
+        return $this->getCodeResponse(204);
+    }
+
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersShow.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersShow.php
new file mode 100644
index 00000000000..f02a1c63089
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersShow.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Shows a single UserFilter.
+ */
+class UserFiltersShow extends JsonApiController
+{
+    protected $allowedIncludePaths = ['user-filter-fields'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $userfilter = new \UserFilter($args['id']);
+
+        // The userfilter object has a new ID -> new object not yet existing in database.
+        if ($userfilter->getId() !== $args['id']) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($userfilter);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
new file mode 100644
index 00000000000..309da9b9646
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace JsonApi\Routes\UserFilters;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+
+/**
+ * Updates an existing UserFilter.
+ */
+class UserFiltersUpdate extends JsonApiController
+{
+    use ValidationTrait;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditUserFilters($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $filter = new \UserFilter($args['id']);
+
+        if ($filter['id'] !== $args['id']) {
+            throw new RecordNotFoundException();
+        }
+
+        $json = $this->validate($request);
+
+        $fields = $filter->getFields();
+
+        foreach (self::arrayGet($json, 'data.attributes.filters') as $one) {
+            $classname = '\\' . $one['attributes']['type'];
+            $field = !empty($one['attributes']['typeparam'])
+                ? new $classname($one['attributes']['typeparam'])
+                : new $classname();
+            $field->setValue($one['attributes']['value']);
+            $field->setCompareOperator($one['attributes']['compare-operator']);
+            $filter->addField($field);
+        }
+
+        $filter->id = '';
+
+        return $this->getCreatedResponse($filter);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+        if (!self::arrayHas($json, 'data.attributes')) {
+            return 'Missing `attributes` member of data block.';
+        }
+        if (!self::arrayHas($json, 'data.attributes.filters')) {
+            return 'Missing `filters` member of attributes block.';
+        }
+    }
+
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index a5c1213e689..44bfd04dc5c 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -18,6 +18,8 @@ class SchemaMap
 
             \Avatar::class => Schemas\Avatar::class,
 
+            \AdmissionRule::class => Schemas\AdmissionRule::class,
+
             \BlubberComment::class => Schemas\BlubberComment::class,
             \BlubberStatusgruppeThread::class => Schemas\BlubberStatusgruppeThread::class,
             \BlubberThread::class => Schemas\BlubberThread::class,
@@ -29,6 +31,7 @@ class SchemaMap
             \ConsultationBooking::class => Schemas\ConsultationBooking::class,
             \ConsultationSlot::class => Schemas\ConsultationSlot::class,
             \ConfigValue::class => Schemas\ConfigValue::class,
+            \CourseSet::class => Schemas\CourseSet::class,
             \ContentTermsOfUse::class => Schemas\ContentTermsOfUse::class,
             \Course::class => Schemas\Course::class,
             \CourseMember::class => Schemas\CourseMember::class,
@@ -59,6 +62,8 @@ class SchemaMap
             \File::class => Schemas\File::class,
             \FileRef::class => Schemas\FileRef::class,
             \FolderType::class => Schemas\Folder::class,
+            \UserFilter::class => Schemas\UserFilter::class,
+            \UserFilterField::class => Schemas\UserFilterField::class,
 
             \Courseware\Block::class => Schemas\Courseware\Block::class,
             \Courseware\BlockComment::class => Schemas\Courseware\BlockComment::class,
diff --git a/lib/classes/JsonApi/Schemas/AdmissionRule.php b/lib/classes/JsonApi/Schemas/AdmissionRule.php
new file mode 100644
index 00000000000..ccb07213782
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/AdmissionRule.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+
+class AdmissionRule extends SchemaProvider
+{
+    const TYPE = 'admission-rules';
+
+    public function getId($resource): ?string
+    {
+        return \get_class($resource) . '_' . $resource->getId();
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'type' => \get_class($resource),
+            'name' => $resource->getName(),
+            'description' => $resource->getDescription(),
+            'payload' => $resource->getPayload(),
+            'ruletext' => $resource->toString()
+        ];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/CourseSet.php b/lib/classes/JsonApi/Schemas/CourseSet.php
new file mode 100644
index 00000000000..e37ad4604a2
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/CourseSet.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class CourseSet extends SchemaProvider
+{
+    const TYPE = 'course-sets';
+
+    const REL_RULES = 'admission-rules';
+    const REL_INSTITUTES = 'institutes';
+    const REL_SEMESTER = 'semester';
+    const REL_COURSES = 'courses';
+    const REL_OWNER = 'owner';
+
+    public function getId($courseset): ?string
+    {
+        return $courseset->getId();
+    }
+
+    public function getAttributes($courseset, ContextInterface $context): iterable
+    {
+        return [
+            'name' => $courseset->getName(),
+            'infotext' => $courseset->getInfoText(),
+            'private' => (bool) $courseset->getPrivate(),
+            'algorithm' => $courseset->getAlgorithm(),
+            'algorithm-run' => (bool) $courseset->hasAlgorithmRun(),
+            'num-applicants' => (int) $courseset->getNumApplicants(),
+            'userlists' => (array) $courseset->getUserLists(),
+            'chdate' => date('c', $courseset->getChdate()),
+        ];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->addOwnerRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_OWNER)
+        );
+
+        $relationships = $this->addInstitutesRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_INSTITUTES)
+        );
+
+        $relationships = $this->addCoursesRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_COURSES)
+        );
+
+        $relationships = $this->addRulesRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_RULES)
+        );
+
+        $relationships = $this->addSemesterRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_SEMESTER)
+        );
+
+        return $relationships;
+    }
+
+    private function addRulesRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_RULES),
+            ]
+        ];
+        if ($includeData) {
+            $related = $resource->getAdmissionRules();
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_RULES => $relation]);
+    }
+
+    private function addOwnerRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_OWNER),
+            ]
+        ];
+        if ($includeData) {
+            $related = $resource->getUserId() ? \User::find($resource->getUserId()) : null;
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_OWNER => $relation]);
+    }
+
+    private function addInstitutesRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_INSTITUTES),
+            ]
+        ];
+        if ($includeData) {
+            $related = $resource->getInstituteIds() ? \Institute::findMany(array_keys($resource->getInstituteIds())) : [];
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_INSTITUTES => $relation]);
+    }
+
+    private function addCoursesRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COURSES),
+            ]
+        ];
+        if ($includeData) {
+            $related = \Course::findMany($resource->getCourses());
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_COURSES => $relation]);
+    }
+
+    private function addSemesterRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_SEMESTER),
+            ]
+        ];
+        if ($includeData) {
+            $related = $resource->getSemester() ? \Semester::find($resource->getSemester()) : null;
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_SEMESTER => $relation]);
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/UserFilter.php b/lib/classes/JsonApi/Schemas/UserFilter.php
new file mode 100644
index 00000000000..7e390cfb341
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/UserFilter.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+
+class UserFilter extends SchemaProvider
+{
+    const TYPE = 'user-filters';
+
+    public function getId($userfilter): ?string
+    {
+        return $userfilter->getId();
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $fields = array_map(
+            fn($field) => [
+                'attributes' => [
+                    'type' => get_class($field),
+                    'typeparam' => property_exists($field, 'datafield_id') ? $field->datafield_id : null,
+                    'id' => $field->getId(),
+                    'compare-operator' => $field->getCompareOperator(),
+                    'value' => $field->getValue(),
+                ]
+            ],
+            $resource->getFields()
+        );
+
+        $resource->show_user_count = true;
+        return [
+            'text' => $resource->toString(),
+            'fields' => $fields
+        ];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/UserFilterField.php b/lib/classes/JsonApi/Schemas/UserFilterField.php
new file mode 100644
index 00000000000..82b440cae69
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/UserFilterField.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class UserFilterField extends SchemaProvider
+{
+    const TYPE = 'user-filter-fields';
+
+    const REL_USERS = 'users';
+
+    public function getId($resource): ?string
+    {
+        return get_class($resource) . '_' . $resource->getId();
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'type' => get_class($resource),
+            'typeparam' => property_exists($resource, 'datafield_id') ? $resource->datafield_id : null,
+            'id' => $resource->getId(),
+            'compare-operator' => $resource->getCompareOperator(),
+            'compare-operator-text' => $resource->getCompareOperatorAsText(),
+            'name' => $resource->getName(),
+            'valid-compare-operators' => $resource->getValidCompareOperators(),
+            'valid-values' => $resource->getValidValues(),
+            'value' => $resource->getValue()
+        ];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->addUsersRelationship(
+            $relationships,
+            $resource,
+            $this->shouldInclude($context, self::REL_USERS)
+        );
+
+        return $relationships;
+    }
+
+    private function addUsersRelationship(
+        array $relationships,
+              $resource,
+              $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_USERS),
+            ]
+        ];
+        if ($includeData) {
+            $related = $resource->getUsers();
+            $relation[self::RELATIONSHIP_DATA] = $related;
+        }
+
+        return array_merge($relationships, [self::REL_USERS => $relation]);
+    }
+}
diff --git a/lib/classes/StudipAutoloader.php b/lib/classes/StudipAutoloader.php
index 883adc725fc..2579b2ce65c 100644
--- a/lib/classes/StudipAutoloader.php
+++ b/lib/classes/StudipAutoloader.php
@@ -110,6 +110,19 @@ class StudipAutoloader
         self::$autoload_paths[] = compact('path', 'prefix');
     }
 
+    /**
+     * Returns whether the autoloader has the given path and prefix already stored.
+     */
+    public static function hasAutoloadPath(string $path, string $prefix = ''): bool
+    {
+        $path = self::sanitizePath($path);
+        if ($prefix) {
+            $prefix = rtrim($prefix, '\\') . '\\';
+        }
+
+        return collect(self::$autoload_paths)->contains(compact('path', 'prefix'));
+    }
+
     /**
      * Removes a path from the list of paths.
      *
diff --git a/lib/classes/admission/AdmissionRule.php b/lib/classes/admission/AdmissionRule.php
index 2b826e49ac4..cff3e356e47 100644
--- a/lib/classes/admission/AdmissionRule.php
+++ b/lib/classes/admission/AdmissionRule.php
@@ -17,6 +17,82 @@
 
 abstract class AdmissionRule
 {
+    private static ?array $rules = null;
+
+    /**
+     * Reads all available AdmissionRule subclasses and loads their definitions.
+     *
+     * @param  bool $activeOnly Show only active rules.
+     */
+    public static function getAvailableAdmissionRules(bool $activeOnly = true): array
+    {
+        if (self::$rules === null) {
+            self::$rules = [];
+
+            $query = "SELECT *
+                      FROM `admissionrules`
+                      ORDER BY `id`";
+            DBManager::get()->fetchAll(
+                $query,
+                [],
+                function ($row) {
+                    /** @var class-string<static> $className */
+                    $className = $row['ruletype'];
+
+                    $autoloadPath = $GLOBALS['STUDIP_BASE_PATH'] . DIRECTORY_SEPARATOR . $row['path'];
+                    if (
+                        !class_exists($className)
+                        && is_dir($autoloadPath)
+                        && !StudipAutoloader::hasAutoloadPath($autoloadPath)
+                    ) {
+                        StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'] . DIRECTORY_SEPARATOR . $row['path']);
+                    }
+
+                    try {
+                        $rule = new $className();
+                        self::$rules[$className] = [
+                            'id' => $row['id'],
+                            'name' => $className::getName(),
+                            'description' => $className::getDescription(),
+                            'active' => (bool) $row['active'],
+                        ];
+                    } catch (Exception $e) {
+                    }
+                }
+            );
+        }
+
+        if ($activeOnly) {
+            return array_filter(
+                self::$rules,
+                fn($rule) => $rule['active']
+            );
+        }
+
+        return self::$rules;
+    }
+
+    /**
+     * @param class-string<static> $name
+     */
+    public static function getRule(string $name, string $id = null): ?AdmissionRule
+    {
+        $rules = self::getAvailableAdmissionRules();
+        if (!array_key_exists($name, $rules)) {
+            throw new InvalidArgumentException("Rule '$name' does not exist.");
+        }
+
+        if (func_num_args() === 1 || $id === null) {
+            return new $name();
+        }
+
+        $rule = new $name($id);
+        if ($rule->getId() !== $id) {
+            return null;
+        }
+        return $rule;
+    }
+
     // --- ATTRIBUTES ---
 
     /**
@@ -62,6 +138,17 @@ abstract class AdmissionRule
      */
     public $siblings_override = false;
 
+    /**
+     * Is the admission rule template written in PHP or is it a VueJS component?
+     * Valid values are 'php' and 'vue'.
+     */
+    public string $type = 'php';
+
+    /**
+     * If the template is a VueJS component, give the path here.
+     */
+    public ?string $component = null;
+
     // --- OPERATIONS ---
 
     public function __construct($ruleId = '', $courseSetId = '')
@@ -138,37 +225,6 @@ abstract class AdmissionRule
         return [];
     }
 
-    /**
-     * Reads all available AdmissionRule subclasses and loads their definitions.
-     *
-     * @param  bool $activeOnly Show only active rules.
-     * @return Array
-     */
-    public static function getAvailableAdmissionRules($activeOnly = true)
-    {
-        $rules = [];
-        $where = ($activeOnly ? " WHERE `active`=1" : "");
-        $data = DBManager::get()->query("SELECT * FROM `admissionrules`".$where.
-            " ORDER BY `id` ASC");
-        while ($current = $data->fetch(PDO::FETCH_ASSOC)) {
-            $className = $current['ruletype'];
-            if (is_dir($GLOBALS['STUDIP_BASE_PATH'] . DIRECTORY_SEPARATOR . $current['path'])) {
-                StudipAutoloader::addAutoloadPath($GLOBALS['STUDIP_BASE_PATH'] . DIRECTORY_SEPARATOR . $current['path']);
-                try {
-                    $rule = new $className();
-                    $rules[$className] = [
-                            'id' => $current['id'],
-                            'name' => $className::getName(),
-                            'description' => $className::getDescription(),
-                            'active' => $current['active']
-                        ];
-                } catch (Exception $e) {
-                }
-            }
-        }
-        return $rules;
-    }
-
     /**
      * Get end of validity.
      *
@@ -448,9 +504,22 @@ abstract class AdmissionRule
         return AdmissionRuleCompatibility::exists([get_class($this), $admission_rule]);
     }
 
+    /**
+     * Get fields and settings defining this admission rule as array.
+     */
+    public function getPayload(): array
+    {
+        return [
+            'start-time' => $this->startTime,
+            'end-time' => $this->endTime,
+            'message' => $this->message ?? $this->default_message
+        ];
+    }
+
     public function __clone()
     {
         $this->id = md5(uniqid(get_class($this)));
         $this->courseSetId = null;
     }
+
 }
diff --git a/lib/classes/admission/AdmissionUserList.php b/lib/classes/admission/AdmissionUserList.php
index 3e5a43099fb..654aa5708dc 100644
--- a/lib/classes/admission/AdmissionUserList.php
+++ b/lib/classes/admission/AdmissionUserList.php
@@ -200,6 +200,17 @@ class AdmissionUserList
         return array_values($result);
     }
 
+    /**
+     * Just counts the number of users and returns the value.
+     */
+    public function getUserCount(): int
+    {
+        return (int) DBManager::get()->fetchColumn(
+            "SELECT COUNT(DISTINCT `user_id`) FROM `user_factorlist` WHERE `list_id` = :id",
+            ['id' => $this->getId()]
+        );
+    }
+
     /**
      * Helper function for loading data from DB.
      */
@@ -207,7 +218,7 @@ class AdmissionUserList
     {
         // Load basic data.
         $stmt = DBManager::get()->prepare("SELECT `list_id`, `name`,
-                CAST(`factor` AS UNSIGNED) AS factor, `owner_id`, `mkdate`, `chdate` 
+                CAST(`factor` AS UNSIGNED) AS factor, `owner_id`, `mkdate`, `chdate`
             FROM `admissionfactor` WHERE `list_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
         if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
diff --git a/lib/classes/admission/UserFilter.php b/lib/classes/admission/UserFilter.php
index 692422f5357..fd160d68136 100644
--- a/lib/classes/admission/UserFilter.php
+++ b/lib/classes/admission/UserFilter.php
@@ -32,6 +32,7 @@ class UserFilter
     public $id = '';
 
     public $show_user_count = false;
+
     // --- OPERATIONS ---
 
     /**
diff --git a/lib/classes/admission/UserFilterField.php b/lib/classes/admission/UserFilterField.php
index 4b5132207cf..2a3480700d4 100644
--- a/lib/classes/admission/UserFilterField.php
+++ b/lib/classes/admission/UserFilterField.php
@@ -195,23 +195,29 @@ class UserFilterField
     public static function getAvailableFilterFields()
     {
         if (self::$available_filter_fields === null) {
-        $fields = [];
-        // Load all PHP class files found in the condition field folder.
-        foreach (glob(realpath(dirname(__FILE__).'/userfilter').'/*.php') as $file) {
-            require_once($file);
-            // Try to auto-calculate class name from file name.
-            $className = mb_substr(basename($file), 0, mb_strpos(basename($file), '.php'));
-            // Check if class is right.
-            if (is_subclass_of($className, 'UserFilterField')) {
-                if ($className::$isParameterized) {
-                    $fields = array_merge($fields, $className::getParameterizedTypes());
+            $fields = [];
+            $i = new FileSystemIterator(
+                $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/admission/userfilter',
+                FileSystemIterator::SKIP_DOTS
+            );
+
+            foreach ($i as $class) {
+                require_once $class;
+            }
+
+            $classes = array_filter(
+                get_declared_classes(),
+                fn($c) => is_subclass_of($c, UserFilterField::class)
+            );
+            foreach ($classes as $class) {
+                if ($class::$isParameterized) {
+                    $fields = array_merge($fields, $class::getParameterizedTypes());
                 } else {
-                    $filter = new $className();
-                    $fields[$className] = $filter->getName();
+                    $filter = new $class();
+                    $fields[$class] = $filter->getName();
                 }
             }
-        }
-        asort($fields);
+            asort($fields);
             self::$available_filter_fields = $fields;
         }
         return self::$available_filter_fields;
@@ -235,7 +241,7 @@ class UserFilterField
      */
     public function getCompareOperatorAsText()
     {
-        return $this->getValidCompareOperators()[$this->compareOperator];
+        return $this->getValidCompareOperators()[$this->compareOperator] ?? '';
     }
 
     /**
diff --git a/resources/assets/javascripts/bootstrap/admission.js b/resources/assets/javascripts/bootstrap/admission.js
index 4b13af13965..68b818a74d2 100644
--- a/resources/assets/javascripts/bootstrap/admission.js
+++ b/resources/assets/javascripts/bootstrap/admission.js
@@ -2,7 +2,36 @@
  * Anmeldeverfahren und -sets
  * ------------------------------------------------------------------------ */
 
-STUDIP.domReady(function () {
+STUDIP.ready(function () {
+
+    /**
+     * Check for admission rules with Vue components
+     * @type {NodeListOf<Element>}
+     */
+    const containers = document.querySelectorAll('[data-admission-rule]');
+
+    containers.forEach(container => {
+
+        const ruleType = container.dataset.admissionRule;
+
+        if (STUDIP.Admission.availableRules[ruleType] !== undefined) {
+
+            import('@/vue/components/admission/' + STUDIP.Admission.availableRules[ruleType])
+                .then(result => {
+                    const components = {};
+                    components[ruleType] = result.default;
+
+                    STUDIP.Vue.load().then(({ createApp }) => {
+                        createApp({
+                            el: container,
+                            components: components
+                        });
+                    });
+                });
+
+        }
+    });
+
     $(document).on('change', 'tr.course input', function(i) {
         STUDIP.Admission.toggleNotSavedAlert();
     });
diff --git a/resources/assets/javascripts/lib/admission.js b/resources/assets/javascripts/lib/admission.js
index df62bbe830d..4b135118930 100644
--- a/resources/assets/javascripts/lib/admission.js
+++ b/resources/assets/javascripts/lib/admission.js
@@ -5,6 +5,22 @@ import { $gettext } from './gettext';
 import Dialog from './dialog.js';
 
 const Admission = {
+
+    /**
+     * All registered rule types with their corresponding Vue components
+     */
+    availableRules: {
+        ConditionalAdmission: 'ConditionalAdmission.vue',
+        CourseMemberAdmission: 'CourseMemberAdmission.vue',
+        LimitedAdmission: 'LimitedAdmission.vue',
+        LockedAdmission: 'LockedAdmission.vue',
+        ParticipantRestrictedAdmission: 'ParticipantRestrictedAdmission.vue',
+        PasswordAdmission: 'PasswordAdmission.vue',
+        PreferentialAdmission: 'PreferentialAdmission.vue',
+        TermsAdmission: 'TermsAdmission.vue',
+        TimedAdmission: 'TimedAdmission.vue'
+    },
+
     getCourses: function(targetUrl) {
         var courseFilter = $('input[name="course_filter"]').val();
         if (courseFilter == '') {
diff --git a/resources/assets/stylesheets/scss/admission.scss b/resources/assets/stylesheets/scss/admission.scss
index 02badc55983..d9d978c77d4 100644
--- a/resources/assets/stylesheets/scss/admission.scss
+++ b/resources/assets/stylesheets/scss/admission.scss
@@ -34,6 +34,60 @@
     }
 }
 
+.institute-assignment,
+.rule-assignment,
+.course-assignment {
+
+    span {
+        display: inline-block;
+        margin-left: 15px;
+        vertical-align: top;
+    }
+
+    .edit-assignment,
+    .delete-assignment {
+        display: inline;
+        vertical-align: middle;
+    }
+}
+
+form.default {
+    fieldset.select_terms_of_use {
+
+        > label {
+            padding: 10px 7px;
+        }
+
+        .admission-rule-incompatible {
+            border: 1px solid var(--content-color-40);
+            color: var(--content-color-80);
+            display: block;
+            margin: -1px 0;
+            padding: 10px 5px 10px 7px;
+
+            div {
+                text-decoration: unset;
+            }
+
+            img {
+                margin-right: 10px;
+                margin-top: -5px;
+                vertical-align: middle;
+            }
+        }
+    }
+
+    .admission-condition {
+        .condition-description {
+            display: inline-block;
+        }
+
+        img {
+            vertical-align: text-bottom;
+        }
+    }
+}
+
 #userlists {
     div {
         margin-bottom: 10px;
@@ -50,3 +104,13 @@
         }
     }
 }
+
+form {
+    fieldset {
+        section {
+            .search-button {
+                margin-top: 25px;
+            }
+        }
+    }
+}
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index 681ade370f9..eb4895ba529 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -14,7 +14,6 @@
 @import "scss/activityfeed";
 @import "scss/admin";
 @import "scss/admin-courses";
-@import "scss/admission";
 @import "scss/article";
 @import "scss/ajax";
 @import "scss/avatar";
@@ -107,6 +106,7 @@
 @import "scss/user-administration";
 @import "scss/wiki";
 @import "scss/multi_person_search";
+@import "scss/admission";
 
 // Class for DOM elements that should only be visible to Screen readers
 .sr-only {
diff --git a/resources/vue/components/StudipUserFilter.vue b/resources/vue/components/StudipUserFilter.vue
new file mode 100644
index 00000000000..f9e6741d171
--- /dev/null
+++ b/resources/vue/components/StudipUserFilter.vue
@@ -0,0 +1,128 @@
+<template>
+    <studip-dialog :title="$gettext('Bedingung hinzufügen')"
+                   height="600"
+                   width="900"
+                   :confirmText="$gettext('Ãœbernehmen')"
+                   confirmClass="button accept"
+                   @confirm="submit"
+                   :closeText="$gettext('Abbrechen')"
+                   closeClass="button cancel"
+                   @close="close">
+        <template v-slot:dialogContent>
+            <section v-for="(element, index) in currentFilter"
+                     :key="index">
+                <p v-if="index >= 1">
+                    {{ $gettext('und') }}
+                </p>
+                <select v-if="availableFields.length > 0"
+                        v-model="element.attributes.type"
+                        @change="addFieldConfig(element.attributes.type, index)"
+                        :aria-label="$gettext('Feldname')">
+                    <option v-for="(field, fIndex) in availableFields"
+                            :key="fIndex"
+                            :value="field.attributes.type">
+                        {{ field.attributes.name }}
+                    </option>
+                </select>
+                <select v-if="hasMultipleCompareOps"
+                        v-model="element.attributes['compare-operator']"
+                        :aria-label="$gettext('Vergleichsoperator')">
+                    <option v-for="(name, op) in fieldConfig[element.attributes.type]?.compareOps"
+                            :key="op"
+                            :value="op">
+                        {{ name }}
+                    </option>
+                </select>
+                <select v-if="hasMultipleValues"
+                        v-model="element.attributes.value"
+                        :aria-label="$gettext('Wert')">
+                    <option v-for="(name, value) in fieldConfig[element.attributes.type]?.values"
+                            :key="value"
+                            :value="value">
+                        {{ name }}
+                    </option>
+                </select>
+                <studip-icon v-if="element.attributes.type && currentFilter.length > 1"
+                             shape="trash"
+                             role="button"
+                             :title="$gettext('Dieses Feld löschen')"
+                             @click="removeField(index)"></studip-icon>
+            </section>
+            <section>
+                <button class="button add"
+                        @click.prevent="addField">
+                    {{ $gettext('Feld hinzufügen') }}
+                </button>
+            </section>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+export default {
+    name: 'StudipUserFilter',
+    props: {
+        filter: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            availableFields: [],
+            currentFilter: this.filter,
+            fieldConfig: {}
+        }
+    },
+    methods: {
+        addFieldConfig(type, fieldIndex) {
+            if (type !== '') {
+                if (!this.fieldConfig[type]) {
+                    for (let i = 0; i < this.availableFields.length; i++) {
+                        if (this.availableFields[i].attributes.type === type) {
+                            this.fieldConfig[type] = {
+                                typeparam: this.availableFields[i].attributes['typeparam'],
+                                compareOps: this.availableFields[i].attributes['valid-compare-operators'],
+                                values: this.availableFields[i].attributes['valid-values']
+                            };
+                        }
+                    }
+                }
+                this.currentFilter[fieldIndex].attributes.type = type;
+                this.currentFilter[fieldIndex].attributes.typeparam = this.fieldConfig[type].typeparam;
+                this.currentFilter[fieldIndex].attributes['compare-operator'] = Object.keys(this.fieldConfig[type].compareOps)[0];
+                this.currentFilter[fieldIndex].attributes.value = Object.keys(this.fieldConfig[type].values)[0];
+            }
+        },
+        addField() {
+            this.currentFilter.push({attributes: { type: null, typeparam: null, 'compare-operator': '', value: '' }});
+            this.addFieldConfig(this.availableFields[0].attributes.type, this.currentFilter.length - 1);
+        },
+        removeField(index) {
+            this.currentFilter.splice(index, 1);
+        },
+        submit() {
+            this.$emit('submit', this.currentFilter)
+        },
+        close() {
+            this.$emit('close');
+        },
+        hasMultipleCompareOps(element) {
+            return element.attributes.type
+                && element.attributes.type !== ''
+                && Object.keys(this.fieldConfig[element.attributes.type]?.compareOps).length > 1;
+        },
+        hasMultipleValues(element) {
+            return element.attributes.type
+                && element.attributes.type !== ''
+                && Object.keys(this.fieldConfig[element.attributes.type]?.values).length > 1;
+        }
+    },
+    created() {
+        STUDIP.jsonapi.withPromises().get('user-filter-fields').then(response => {
+            this.availableFields = response.data;
+            this.addField();
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/AdmissionRuleConfig.vue b/resources/vue/components/admission/AdmissionRuleConfig.vue
new file mode 100644
index 00000000000..b9691a832ba
--- /dev/null
+++ b/resources/vue/components/admission/AdmissionRuleConfig.vue
@@ -0,0 +1,89 @@
+<template>
+    <studip-dialog v-if="component !== null"
+                   :title="$gettext('Anmelderegel bearbeiten')"
+                   :close-text="$gettext('Abbrechen')"
+                   @close="cancel"
+                   width="900"
+                   height="600">
+        <template v-slot:dialogContent>
+            <studip-message-box v-if="invalidData?.length"
+                                type="error"
+                                :details="invalidData"
+                                :hide-close="true"
+                                :hide-details="false"
+                                :aria-description="errorText"
+                                role="alert">
+                {{ $gettext('Es sind ungültige Daten angegeben worden:') }}
+            </studip-message-box>
+            <component :is="component" v-bind="props" @submit="submit" @error="error"></component>
+        </template>
+        <template v-slot:dialogButtons>
+            <button type="button"
+                    class="button accept"
+                    @click="requireData">
+                {{ $gettext('Ãœbernehmen') }}
+            </button>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+export default {
+    name: 'AdmissionRuleConfig',
+    props: {
+        type: {
+            type: String,
+            required: true
+        },
+        rule: {
+            type: Object,
+            default: null
+        },
+        assignedRuleTypes: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            component: null,
+            theRule: this.rule,
+            props: null,
+            invalidData: null
+        }
+    },
+    computed: {
+        errorText() {
+            return this.$gettext('Es sind ungültige Daten angegeben worden:') + this.invalidData?.join(',');
+        }
+    },
+    methods: {
+        requireData() {
+            STUDIP.eventBus.emit('getRuleConfiguration');
+        },
+        cancel() {
+            this.component = null;
+            this.$emit('cancel');
+        },
+        submit(data) {
+            this.component = null;
+            this.$emit('submit', data);
+        },
+        error(message) {
+            this.invalidData = message;
+        }
+    },
+    created() {
+        const file = STUDIP.Admission.availableRules[this.type];
+        let components = {};
+        import(`@/vue/components/admission/${file}`).then((module) => {
+            this.component = module.default;
+            this.props = {
+                id: this.theRule?.id,
+                ruleData: this.theRule,
+                assignedRuleTypes: this.assignedRuleTypes
+            };
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/AdmissionRuleTypeSelector.vue b/resources/vue/components/admission/AdmissionRuleTypeSelector.vue
new file mode 100644
index 00000000000..498497aa5a1
--- /dev/null
+++ b/resources/vue/components/admission/AdmissionRuleTypeSelector.vue
@@ -0,0 +1,113 @@
+<template>
+    <studip-dialog @close="closeDialog"
+                   width="900"
+                   height="600"
+                   :close-text="$gettext('Abbrechen')"
+                   :title="$gettext('Anmelderegel konfigurieren')">
+        <template v-slot:dialogContent>
+            <form v-if="!loading"
+                  class="default">
+                <fieldset class="select_terms_of_use">
+                    <legend>
+                        {{ $gettext('Typ der Anmelderegel auswählen') }}
+                    </legend>
+                    <template v-for="type in ruleTypes">
+                        <input v-if="isAvailable(type.attributes.type)"
+                               type="radio"
+                               v-model="selectedType"
+                               :value="type.attributes.type"
+                               :id="'rule-type-' + type.attributes.type"
+                               :key="type.attributes.type + '-input'">
+                        <label v-if="isAvailable(type.attributes.type)"
+                               :for="'rule-type-' + type.attributes.type"
+                               :key="type.attributes.type + '-label'">
+                            <studip-icon :shape="type.attributes.type === selectedType
+                                            ? 'radiobutton-checked'
+                                            : 'radiobutton-unchecked'"
+                                         :size="24"
+                            ></studip-icon>
+                            <div class="text">
+                                {{ type.attributes.name }}
+                            </div>
+                        </label>
+                        <div v-if="isAvailable(type.attributes.type)"
+                             class="terms_of_use_description"
+                             :key="type.id + '-description'">
+                            {{ type.attributes.description }}
+                        </div>
+                        <div v-if="!isAvailable(type.attributes.type)"
+                           :key="type.id + '-incompatible'"
+                           class="admission-rule-incompatible">
+                            <studip-icon shape="remove-circle"
+                                         :size="24"
+                                         role="inactive"
+                            ></studip-icon>
+                            {{ type.attributes.name }}
+                            ({{ $gettext('nicht mit bereits vorhandenen Regeln kompatibel') }})
+                        </div>
+                    </template>
+                </fieldset>
+            </form>
+            <studip-progress-indicator v-if="loading"
+                                       :size="32"
+                                       :description="$gettext('Verfügbare Anmelderegeln werden geladen')"
+            ></studip-progress-indicator>
+        </template>
+        <template v-slot:dialogButtons>
+            <button type="button"
+                    class="button"
+                    @click.prevent="configureRule"
+                    :disabled="selectedType === null">
+                {{ $gettext('Ausgewählte Regel konfigurieren') }}
+            </button>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipDialog from '../StudipDialog.vue';
+
+export default {
+    name: 'AdmissionRuleTypeSelector',
+    components: { StudipProgressIndicator, StudipDialog },
+    props: {
+        assignedRuleTypes: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            loading: true,
+            ruleTypes: [],
+            selectedType: null,
+            compatibility: {}
+        }
+    },
+    methods: {
+        closeDialog() {
+            this.$emit('close');
+        },
+        configureRule() {
+            this.$emit('configureRule', this.selectedType);
+        },
+        isAvailable(ruleType) {
+            return this.assignedRuleTypes.every(t => this.compatibility[ruleType]?.includes(t));
+        }
+    },
+    created() {
+        Promise.all([
+            STUDIP.jsonapi.withPromises().get('admission-rules'),
+            STUDIP.jsonapi.withPromises().get('admission/rule-compatibility')
+        ]).then(values => {
+            this.loading = false;
+            this.ruleTypes = values[0].data;
+            this.compatibility = values[1];
+            this.ruleTypes.forEach(t => {
+                this.isAvailable(t.attributes.type);
+            });
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/ConditionalAdmission.vue b/resources/vue/components/admission/ConditionalAdmission.vue
new file mode 100644
index 00000000000..426c6f24f93
--- /dev/null
+++ b/resources/vue/components/admission/ConditionalAdmission.vue
@@ -0,0 +1,194 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <validity-time></validity-time>
+        <section>
+            <h3>
+                {{ $gettext('Anmeldebedingungen') }}
+            </h3>
+            <div v-if="ungrouped?.length > 0"
+                 role="list">
+                <div v-for="(filter, index) in ungrouped"
+                     :key="index"
+                     class="admission-condition"
+                     role="listitem">
+                    <p v-if="ungrouped.length > 1 && index >= 1">
+                        {{ $gettext('oder') }}
+                    </p>
+                    <p v-if="!groupsAllowed"
+                       class="condition-description"
+                       v-html="filter.attributes.text"></p>
+                    <label v-else class="undecorated">
+                        <input type="checkbox"
+                               v-model="selectedFilters"
+                               :value="filter.id">
+                        <span v-html="filter.attributes.text"></span>
+                    </label>
+                    <a @click.prevent="deleteFilter(index)"
+                       :title="$gettext('Diese Bedingung löschen')">
+                        <studip-icon shape="trash"></studip-icon>
+                    </a>
+                </div>
+                <button v-if="selectedFilters?.length > 0"
+                        class="button"
+                        @click.prevent="createContingent">
+                    {{ $gettext('Kontingent erstellen') }}
+                </button>
+            </div>
+            <div v-if="groups?.length > 0"
+                 role="list">
+                <div v-for="(group, index) in groups"
+                     :key="index"
+                     class="admission-contingent"
+                     role="listitem">
+                    <div class="col-3">
+                        <label>
+                            {{ $gettext('Kontingent in Prozent') }}:
+                            <input type="number"
+                                   min="0"
+                                   max="100"
+                                   v-model="group.quota">
+                        </label>
+                        <ul>
+                            <li v-for="(filter, fIndex) in group.conditions"
+                                :key="fIndex">
+                                <p v-html="filter.attributes.text"></p>
+                            </li>
+                        </ul>
+                    </div>
+                    <button type="button"
+                            class="undecorated delete-contingent"
+                            tabindex="0"
+                            :title="$gettext('Kontingent auflösen')"
+                            @click.prevent="deleteContingent(index)">
+                        <studip-icon shape="trash"
+                                     :size="20"></studip-icon>
+                    </button>
+                </div>
+            </div>
+            <p v-if="ungrouped?.length + groups?.length === 0">
+                {{ $gettext('Sie haben noch keine Bedingungen festgelegt.') }}
+            </p>
+        </section>
+        <section>
+            <button class="button add"
+                    @click.prevent="editFilter">
+                {{ $gettext('Bedingung hinzufügen') }}
+            </button>
+        </section>
+        <studip-user-filter v-if="showEditFilter"
+                            @submit="confirmDialog"
+                            @close="closeDialog"></studip-user-filter>
+    </form>
+</template>
+
+<script>
+import axios from 'axios';
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+import ValidityTime from "./ValidityTime.vue";
+import StudipUserFilter from "../StudipUserFilter.vue";
+
+export default {
+    name: 'ConditionalAdmission',
+    components: { StudipUserFilter, ValidityTime},
+    mixins: [AdmissionRuleMixin],
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Zur Anmeldung müssen diese Bedingungen erfüllt sein: %s'),
+            ungrouped: [],
+            groups: [],
+            showEditFilter: false,
+            selectedFilters: []
+        }
+    },
+    computed: {
+        groupsAllowed() {
+            return this.assignedRuleTypes.includes('ParticipantRestrictedAdmission')
+        },
+        payload() {
+            return {
+                type: 'ConditionalAdmission',
+                payload: {
+                    conditions: this.ungrouped,
+                    'grouped-conditions': this.groups,
+                    'conditiongroups-allowed': this.groupsAllowed,
+                    message: this.message
+                }
+            }
+        }
+    },
+    methods: {
+        editFilter() {
+            this.showEditFilter = true;
+        },
+        deleteFilter(index) {
+            this.ungrouped.splice(index, 1);
+        },
+        closeDialog() {
+            this.showEditFilter = false;
+        },
+        confirmDialog(filter) {
+            STUDIP.jsonapi.withPromises().post(
+                'user-filters',
+                {
+                    data: {
+                        data: {
+                            attributes: {
+                                filters: filter
+                            }
+                        }
+                    }
+                })
+            .then(response => {
+                this.ungrouped.push(response.data);
+                this.showEditFilter = false;
+            });
+        },
+        setRuleData(data) {
+            this.ungrouped = data.attributes.payload['conditions'];
+            this.groups = data.attributes.payload['grouped-conditions'];
+        },
+        validate() {
+            if (this.ungrouped.length + this.groups.length === 0) {
+                this.invalidData.push(this.$gettext('Bitte geben Sie mindestens eine Auswahlbedingung an.'));
+            }
+
+            return this.invalidData.length === 0;
+        },
+        createContingent() {
+            let setQuotas = 100;
+            this.groups.forEach(group => {
+                setQuotas -= group.quota;
+            });
+            this.groups.push({
+                id: null,
+                quota: Math.max(setQuotas, 0),
+                conditions: this.ungrouped.filter(element => {
+                    return this.selectedFilters.includes(element.id);
+                })
+            });
+            this.ungrouped = this.ungrouped.filter(element => {
+                return !this.selectedFilters.includes(element.id);
+            });
+            this.selectedFilters = [];
+        },
+        deleteContingent(index) {
+            this.groups[index].conditions.forEach(filter => {
+                this.ungrouped.push(filter);
+            });
+            this.groups.splice(index, 1);
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+.delete-contingent {
+    margin-top: 2ex;
+}
+</style>
diff --git a/resources/vue/components/admission/ConfigureCourseSet.vue b/resources/vue/components/admission/ConfigureCourseSet.vue
new file mode 100644
index 00000000000..f23f17250e0
--- /dev/null
+++ b/resources/vue/components/admission/ConfigureCourseSet.vue
@@ -0,0 +1,712 @@
+<template>
+    <div>
+        <form class="default"
+              :action="storeUrl"
+              method="post"
+              ref="courseSetForm"
+              data-secure="true"
+        >
+            <fieldset>
+                <legend>{{ $gettext('Grunddaten') }}</legend>
+                <section>
+                    <label class="studiprequired">
+                        <span class="textlabel">
+                            {{ $gettext('Name des Anmeldesets') }}
+                        </span>
+                        <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                        <input type="text"
+                               name="name"
+                               maxlength="255"
+                               v-model="name">
+                    </label>
+                </section>
+                <section>
+                    <label for="private"
+                           :aria-label="$gettext('Dieses Anmeldeset soll nur für mich selbst und alle Administratoren sichtbar und benutzbar sein.')">
+                        {{ $gettext('Sichtbarkeit') }}
+                    </label>
+                    <input type="checkbox"
+                           name="private"
+                           id="private"
+                           v-model="private">
+                    {{ $gettext('Dieses Anmeldeset soll nur für mich selbst und alle Administratoren sichtbar und benutzbar sein.') }}
+                </section>
+            </fieldset>
+            <fieldset>
+                <legend>{{ $gettext('Einrichtungszuordnung') }}</legend>
+                <section v-if="instituteSearch || myInstitutes?.length > 1">
+                    <label for="isearch" class="studiprequired">
+                        <span class="textlabel">
+                            {{ $gettext('Einrichtung wählen') }}
+                        </span>
+                        <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                    </label>
+                    <quicksearch v-if="instituteSearch"
+                                 :searchtype="instituteSearch"
+                                 name="institute"
+                                 :key="NaN"
+                                 id="isearch"
+                                 @input="addInstitute"
+                                 :aria-label="$gettext('Geben Sie einen Suchbegriff mit mehr als 3 Zeichen ein, um nach Einrichtungen zu suchen')"
+                                 ref="instituteSearch"></quicksearch>
+                    <select v-if="myInstitutes?.length > 1"
+                            name="institute"
+                            id="isearch"
+                            @change.prevent="setInstitute">
+                        <option value="">-- {{ $gettext('bitte wählen') }} --</option>
+                        <option v-for="institute in myInstitutes"
+                                :key="institute.id"
+                                :value="institute.id"
+                        >
+                            {{ institute.name }}
+                        </option>
+                    </select>
+                </section>
+                <section>
+                    <header>
+                        <h2>{{ $gettext('Bereits zugeordnet') }}</h2>
+                    </header>
+                    <table v-if="institutes?.length > 0" class="default assignments">
+                        <tbody>
+                            <tr v-for="(institute, index) in institutes"
+                                :key="institute.id"
+                                class="institute-assignment">
+                                <td>
+                                    <input type="hidden"
+                                           name="institutes[]"
+                                           :value="institute.id">
+                                    {{ institute.name }}
+                                </td>
+                                <td class="actions">
+                                    <button v-if="myInstitutes?.length !== 1"
+                                            :title="$gettextInterpolate(
+                                                $gettext('Zuordnung der Einrichtung %{name} entfernen'),
+                                                { name: institute.name }
+                                                )"
+                                            :aria-label="$gettextInterpolate(
+                                                $gettext('Zuordnung der Einrichtung %{name} entfernen'),
+                                                { name: institute.name }
+                                                )"
+                                            class="as-link delete-assignment"
+                                            tabindex="0"
+                                            @click.prevent="removeInstitute(index)">
+                                        <studip-icon shape="trash"></studip-icon>
+                                    </button>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                    <p v-else>
+                        {{ $gettext('Aktuell sind keine Einrichtungen zugeordnet.') }}
+                    </p>
+                </section>
+            </fieldset>
+            <fieldset v-if="institutes?.length > 0 || courses?.length > 0">
+                <legend>
+                    {{ $gettext('Veranstaltungszuordnung') }}
+                </legend>
+                <section>
+                    <template v-if="!isSearching">
+                        <label class="col-2">
+                            {{ $gettext('Semester') }}
+                            <select ref="semesterChooser"
+                                    v-model="selectedSemester"
+                                    @change.prevent="getAvailableCourses">
+                                <option v-for="semester in allSemesters"
+                                        :key="semester.id"
+                                        :value="semester.id">
+                                    {{ semester.name }}
+                                </option>
+                            </select>
+                        </label>
+                        <label class="col-3">
+                            {{ $gettext('Suche nach Titel, Nummer, Lehrenden (mehr als 3 Zeichen)') }}
+                            <input type="text"
+                                   v-model="courseSearchterm"
+                                   @keydown.enter.prevent="getAvailableCourses"
+                                   ref="courseSearch"/>
+                        </label>
+                        <button class="button search-button"
+                                :disabled="!canSearchCourses"
+                                @click.prevent="getAvailableCourses">
+                            {{ $gettext('Suche') }}
+                        </button>
+                    </template>
+                    <studip-progress-indicator v-else :size="32"
+                                               :description="$gettext('Veranstaltungen werden gesucht...')"/>
+                </section>
+                <section>
+                    <table v-if="availableCourses?.length > 0"
+                           class="default">
+                        <caption>
+                            {{ $gettextInterpolate($gettext('Veranstaltungen im %{semester}'),
+                                { semester: allSemesters[selectedSemester].name }) }}
+                        </caption>
+                        <colgroup>
+                            <col style="width: 15px">
+                            <col>
+                        </colgroup>
+                        <thead>
+                            <tr>
+                                <th colspan="2">
+                                    {{ $gettext('Veranstaltung') }}
+                                </th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <tr v-for="course in availableCourses" :key="course.id">
+                                <td>
+                                    <label>
+                                        <input type="checkbox"
+                                               :value="course.id"
+                                               v-model="checkedCourses"
+                                               :title="$gettextInterpolate($gettext('Veranstaltung %{coursename} dem Anmeldeset zuordnen'),
+                                                { coursename: course.attributes.title })">
+                                        <template v-if="course.attributes['course-number']">
+                                            {{ course.attributes['course-number'] }}
+                                        </template>
+                                        {{ course.attributes.title }}
+                                    </label>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                    <studip-message-box v-if="!isSearching && noCoursesFound"
+                                        type="info"
+                                        :hide-close="true"
+                                        role="alert">
+                        {{ $gettext('Es wurden keine Veranstaltungen gefunden, die zugeordnet werden könnten.') }}
+                    </studip-message-box>
+                </section>
+                <table v-if="courses?.length > 0"
+                       class="default assignments">
+                    <caption>{{ $gettext('Bereits zugeordnet') }}</caption>
+                    <thead>
+                    </thead>
+                    <tbody>
+                        <tr v-for="(course, index) in courses"
+                            :key="course.id"
+                            class="course-assignment"
+                        >
+                            <td>
+                                <template v-if="course.attributes['course-number']">
+                                    {{ course.attributes['course-number'] }}
+                                </template>
+                                {{ course.attributes.title }}
+                            </td>
+                            <td class="actions">
+                                <button :title="$gettextInterpolate(
+                                            $gettext('Zuordnung der Veranstaltung %{name} entfernen'),
+                                            { name: course.attributes.title })"
+                                        :aria-label="$gettextInterpolate(
+                                            $gettext('Zuordnung der Veranstaltung %{name} entfernen'),
+                                            { name: course.attributes.title })"
+                                        class="as-link delete-assignment"
+                                        tabindex="0"
+                                        @click.prevent="removeCourse(index)">
+                                    <studip-icon shape="trash"></studip-icon>
+                                </button>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+                <button v-if="hasConfigurableCourses"
+                        class="button"
+                        @click.prevent="configureCourses">
+                    {{ $gettext('Veranstaltungen konfigurieren') }}
+                </button>
+                <button v-if="numApplicants > 0"
+                        class="button"
+                        @click.prevent="getApplicants">
+                    {{ $gettextInterpolate(
+                        $gettext('Liste der Anmeldungen (%{number} Personen)'),
+                        { number: numApplicants }) }}
+                </button>
+                <button v-if="numApplicants > 0"
+                        class="button"
+                        @click.prevent="messageApplicants">
+                    {{ $gettext('Nachricht an alle Angemeldeten') }}
+                </button>
+            </fieldset>
+            <fieldset>
+                <legend class="studiprequired">
+                    <span class="textlabel">
+                        {{ $gettext('Anmelderegeln') }}
+                    </span>
+                    <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span>
+                </legend>
+                <section>
+                    <table v-if="rules.length > 0" class="default assignments">
+                        <tbody>
+                            <tr v-for="(rule, index) in rules"
+                                :key="index"
+                                class="rule-assignment"
+                            >
+                                <td v-html="rule.attributes.ruletext"></td>
+                                <td class="actions">
+                                    <button :title="$gettextInterpolate(
+                                                $gettext('Regel %{name} bearbeiten'),
+                                                { name: rule.attributes.name }
+                                                )"
+                                            :aria-label="$gettextInterpolate(
+                                                $gettext('Regel %{name} bearbeiten'),
+                                                { name: rule.attributes.name }
+                                                )"
+                                            class="as-link edit-assignment"
+                                            tabindex="0"
+                                            @click.prevent="configureRule(rule.attributes.type, rule, index)">
+                                        <studip-icon shape="edit" :size="16"></studip-icon>
+                                    </button>
+                                    <button :title="$gettextInterpolate(
+                                                $gettext('Regel %{name} entfernen'),
+                                                { name: rule.attributes.name }
+                                                )"
+                                            :aria-label="$gettextInterpolate(
+                                                $gettext('Regel %{name} entfernen'),
+                                                { name: rule.attributes.name }
+                                                )"
+                                            class="as-link delete-assignment"
+                                            tabindex="0"
+                                            data-confirm="$gettext('Soll die Regel wirklich entfernt werden?')"
+                                            @click.prevent="removeRule(index)">
+                                        <studip-icon shape="trash"></studip-icon>
+                                    </button>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                    <button class="button add add-rule-button"
+                            type="button"
+                            @click="addRule"
+                    >
+                        {{ $gettext('Anmelderegel hinzufügen') }}
+                    </button>
+                </section>
+            </fieldset>
+            <fieldset>
+                <legend>
+                    {{ $gettext('Weitere Daten') }}
+                </legend>
+                <section v-if="hasUserLists">
+                    <label>
+                        {{ $gettext('Personen mit Bonus/Malus bei der Platzverteilung') }}
+                    </label>
+                    <label v-for="list in myUserLists" :key="list.id">
+                        <input type="checkbox" :value="list.id" v-model="userLists">
+                        {{ list.name }}
+                        ({{ userListText(list.factor, list.count) }})
+                    </label>
+                    <button v-if="showUserListUsers"
+                            class="button"
+                            @click.prevent="openUserListUsers">
+                        {{ $gettext('Liste der Personen') }}
+                    </button>
+                </section>
+                <section>
+                    <label>
+                        {{ $gettext('Weitere Hinweise für die Teilnehmenden') }}
+                        <textarea name="infotext"
+                                  cols="60"
+                                  rows="3"
+                                  v-model="additional"></textarea>
+                    </label>
+                </section>
+            </fieldset>
+            <footer data-dialog-button>
+                <button class="button accept"
+                        type="submit"
+                        @click.prevent="storeCourseset"
+                        :disabled="!isStorable">
+                    {{ $gettext('Speichern') }}
+                </button>
+                <button class="button cancel"
+                        type="button"
+                        data-dialog="close"
+                        @click.prevent="cancel"
+                >
+                    {{ $gettext('Abbrechen') }}
+                </button>
+            </footer>
+        </form>
+        <admission-rule-type-selector v-if="showRuleSelector"
+                                      :assigned-rule-types="ruleTypes"
+                                      @configureRule="configureRule"
+                                      @close="closeRuleSelector"
+        ></admission-rule-type-selector>
+        <admission-rule-config v-if="showRuleConfig && ruleType !== ''"
+                               :type="ruleType"
+                               :rule="singleRule"
+                               :assigned-rule-types="ruleTypes"
+                               @submit="addRuleConfiguration"
+                               @cancel="closeRuleConfig"
+        ></admission-rule-config>
+    </div>
+</template>
+
+<script>
+import quicksearch from '../Quicksearch.vue';
+import AdmissionRuleTypeSelector from './AdmissionRuleTypeSelector.vue';
+import AdmissionRuleConfig from './AdmissionRuleConfig.vue';
+import StudipProgressIndicator from "../StudipProgressIndicator.vue";
+
+export default {
+    name: 'ConfigureCourseSet',
+    components: {StudipProgressIndicator, AdmissionRuleConfig, quicksearch, AdmissionRuleTypeSelector },
+    props: {
+        courseSetId: {
+            type: String,
+            default: ''
+        },
+        allSemesters: {
+            type: Object,
+            required: true
+        },
+        semester: {
+            type: String,
+            default: ''
+        },
+        instituteSearch: {
+            type: String,
+            default: ''
+        },
+        myInstitutes: {
+            type: Array,
+            default: () => []
+        },
+        myUserLists: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            name: '',
+            private: true,
+            numApplicants: 0,
+            institutes: [],
+            selectedSemester: this.semester,
+            courseSearchterm: '',
+            availableCourses: [],
+            isSearching: false,
+            noCoursesFound: false,
+            checkedCourses: [],
+            courses: [],
+            rules: [],
+            userLists: [],
+            hasUserLists: false,
+            additional: '',
+            showRuleSelector: false,
+            ruleType: '',
+            ruleId: '',
+            singleRule: null,
+            ruleIndex: null,
+            showRuleConfig: false,
+            changed: false
+        }
+    },
+    computed: {
+        isStorable() {
+            return this.name !== ''
+                && this.institutes.length > 0
+                && this.rules.length > 0;
+        },
+        hasConfigurableCourses() {
+            return this.courseSetId
+                && this.courseSetId !== ''
+                && this.courses?.length > 0;
+        },
+        storeUrl() {
+            let url = STUDIP.URLHelper.getURL('dispatch.php/admission/courseset/save', {}, true);
+
+            if (this.courseSetId !== null) {
+                url += '/' + this.courseSetId;
+            }
+
+            return url;
+        },
+        ruleTypes() {
+            return this.rules.map(r => r.attributes.type);
+        },
+        canSearchCourses() {
+            return this.courseSearchterm?.trim().length >= 3
+        },
+        showUserListUsers() {
+            return this.courseSetId !== '' && this.hasUserLists && this.userLists.length > 0;
+        }
+    },
+    methods: {
+        getSelectedSemester() {
+            return this.allSemesters[this.selectedSemester];
+        },
+        getAvailableCourses() {
+            if (this.canSearchCourses) {
+                this.noCoursesFound = false;
+                this.isSearching = true;
+                this.availableCourses = [];
+                STUDIP.jsonapi.withPromises().post(
+                    'admission/available-courses',
+                    {
+                        data: {
+                            institutes: this.institutes.map(i => {
+                                return i.id;
+                            }),
+                            courseset: this.courseSetId ? this.courseSetId : null,
+                            exclude: this.courses.map(course => course.id),
+                            semester: this.selectedSemester,
+                            filter: this.courseSearchterm
+                        }
+                    }
+                ).then(response => {
+                    setTimeout(() => this.isSearching = false, 1000);
+                    const currentCourses = this.courses.map(c => c.id);
+                    this.availableCourses = response.data.filter(course => !currentCourses.includes(course.id));
+                    this.noCoursesFound = this.availableCourses.length === 0;
+                }).catch(error => {
+                    this.isSearching = false;
+                    STUDIP.Report.error(this.$gettext('Es ist ein Fehler aufgetreten'), error);
+                });
+            }
+        },
+        addRule() {
+            this.ruleType = '';
+            this.showRuleSelector = true;
+        },
+        closeRuleSelector() {
+            this.ruleType = '';
+            this.ruleId = '';
+            this.singleRule = null;
+            this.showRuleSelector = false;
+        },
+        closeRuleConfig() {
+            this.ruleType = '';
+            this.ruleId = '';
+            this.singleRule = null;
+            this.showRuleConfig = false;
+        },
+        configureRule(type, rule = null, index = null) {
+            this.ruleType = type;
+            this.ruleId = rule?.id;
+            this.singleRule = rule;
+            this.ruleIndex = index;
+            this.showRuleSelector = false;
+            this.showRuleConfig = true;
+        },
+        addRuleConfiguration(data) {
+            if (!this.ruleId) {
+                STUDIP.jsonapi.withPromises().post(
+                    'admission-rules/' + data.type,
+                    {
+                        data: {
+                            data: {
+                                attributes: {
+                                    payload: data.payload
+                                }
+                            }
+                        }
+                    }
+                ).then(response => {
+                    this.ruleType = '';
+                    this.ruleId = '';
+                    this.singleRule = null;
+                    this.showRuleConfig = false;
+                    if (this.ruleIndex !== null) {
+                        this.rules[this.ruleIndex] = response.data;
+                        this.ruleIndex = null;
+                    } else {
+                        this.rules.push(response.data);
+                    }
+                    this.checkForUserLists();
+                });
+            } else {
+                STUDIP.jsonapi.withPromises().patch(
+                    'admission-rules/' + this.ruleId,
+                    {
+                        data: {
+                            data: {
+                                attributes: {
+                                    payload: data.payload
+                                }
+                            }
+                        }
+                    }
+                ).then(response => {
+                    this.ruleType = '';
+                    this.ruleId = '';
+                    this.singleRule = null;
+                    this.showRuleConfig = false;
+                    if (this.ruleIndex !== null) {
+                        this.rules[this.ruleIndex] = response.data;
+                        this.ruleIndex = null;
+                    } else {
+                        this.rules.push(response.data.data);
+                    }
+                    this.checkForUserLists();
+                });
+            }
+        },
+        removeRule(index) {
+            this.rules.splice(index, 1);
+            this.checkForUserLists();
+        },
+        removeCourse(index) {
+            this.courses.splice(index, 1);
+        },
+        setInstitute(evt) {
+            if (evt.currentTarget.value !== '') {
+                this.addInstitute(
+                    evt.currentTarget.value,
+                    evt.currentTarget.options[evt.currentTarget.options.selectedIndex].textContent
+                );
+            }
+        },
+        addInstitute(returnValue, inputValue) {
+            if (!this.institutes.some(i => i.id === returnValue)) {
+                this.institutes.push({ id: returnValue, name: inputValue });
+            }
+        },
+        removeInstitute(index) {
+            this.institutes.splice(index, 1);
+        },
+        storeCourseset() {
+            const data = {
+                data: {
+                    attributes: {
+                        name: this.name,
+                        private: this.private,
+                        infotext: this.additional,
+                        institutes: this.institutes.map(i => i.id),
+                        courses: this.courses.map(c => c.id).concat(this.checkedCourses),
+                        rules: this.rules,
+                        userlists: this.hasUserLists ? this.userLists : []
+                    }
+                }
+            };
+            if (this.courseSetId === '') {
+
+                STUDIP.jsonapi.withPromises().post(
+                    'course-sets',
+                    { data: data }
+                ).then(response => {
+                    this.$refs.courseSetForm.dataset.secure = 'false';
+                    window.location = STUDIP.URLHelper.getURL('dispatch.php/admission/courseset');
+                });
+
+            } else {
+
+                STUDIP.jsonapi.withPromises().patch(
+                    'course-sets/' + this.courseSetId,
+                    { data: data}
+                ).then(response => {
+                    this.$refs.courseSetForm.dataset.secure = 'false';
+                    window.location = STUDIP.URLHelper.getURL('dispatch.php/admission/courseset');
+                });
+
+            }
+        },
+        cancel() {
+            window.location = STUDIP.URLHelper.getURL('dispatch.php/admission/courseset');
+        },
+        configureCourses()
+        {
+            STUDIP.Dialog.fromURL(
+                STUDIP.URLHelper.getURL('dispatch.php/admission/courseset/configure_courses/' + this.courseSetId)
+            );
+        },
+        getApplicants()
+        {
+            STUDIP.Dialog.fromURL(
+                STUDIP.URLHelper.getURL('dispatch.php/admission/courseset/applications_list/' + this.courseSetId)
+            );
+        },
+        messageApplicants()
+        {
+            STUDIP.Dialog.fromURL(
+                STUDIP.URLHelper.getURL('dispatch.php/admission/courseset/applicants_message/' + this.courseSetId)
+            );
+        },
+        checkForUserLists() {
+            const rule = this.rules?.filter(rule => rule.attributes.type === 'ParticipantRestrictedAdmission');
+            this.hasUserLists = this.myUserLists.length > 0
+                && (rule?.length > 0 ? rule[0].attributes.payload['distribution-time'] > 0 : false);
+        },
+        userListText(factor, count) {
+            return this.$gettextInterpolate(
+                factor < 1
+                ? this.$gettext('%{number} Personen werden nachrangig eingetragen')
+                    : this.$gettext('%{number} Personen werden bevorzugt'),
+                { number: count }
+            );
+        },
+        openUserListUsers() {
+            STUDIP.Dialog.fromURL(
+                STUDIP.URLHelper.getURL('dispatch.php/admission/courseset/factored_users/' + this.courseSetId)
+            );
+        }
+    },
+    created() {
+        // Load courseset if an ID is given
+        if (this.courseSetId !== '') {
+            STUDIP.jsonapi.withPromises().get(
+                'course-sets/' + this.courseSetId,
+                {data: {include: 'admission-rules,courses,institutes'}}
+            ).then(courseset => {
+                this.name = courseset.data.attributes.name;
+                this.private = courseset.data.attributes.private;
+                this.additional = courseset.data.attributes.infotext;
+                this.numApplicants = courseset.data.attributes['num-applicants'];
+                this.userLists = courseset.data.attributes['userlists'];
+
+                courseset.included.forEach(entry => {
+                    switch (entry.type) {
+                        case 'institutes':
+                            this.addInstitute(entry.id, entry.attributes.name);
+                            break;
+                        case 'courses':
+                            this.courses.push(entry);
+                            break;
+                        case 'admission-rules':
+                            this.rules.push(entry);
+                            break;
+                    }
+                });
+
+                this.checkForUserLists();
+            });
+        } else if (this.myInstitutes.length === 1) {
+            this.addInstitute(this.myInstitutes[0].id, this.myInstitutes[0].name);
+        }
+
+        if (!this.selectedSemester) {
+            for (const [key, value] of Object.entries(this.allSemesters)) {
+                if (value.current) {
+                    this.selectedSemester = value.id;
+                }
+            }
+        }
+
+        this.getAvailableCourses();
+    }
+}
+</script>
+
+<style lang="scss">
+table.assignments {
+    margin-bottom: unset;
+    width: 50%;
+
+    .actions {
+        text-align: right;
+    }
+}
+
+button {
+    &.add-rule-button {
+        margin-bottom: 0;
+    }
+
+    img {
+        vertical-align: text-bottom;
+    }
+
+}
+</style>
diff --git a/resources/vue/components/admission/CourseMemberAdmission.vue b/resources/vue/components/admission/CourseMemberAdmission.vue
new file mode 100644
index 00000000000..9d59e4746b1
--- /dev/null
+++ b/resources/vue/components/admission/CourseMemberAdmission.vue
@@ -0,0 +1,113 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <validity-time></validity-time>
+        <section>
+            <label>
+                <input type="radio" v-model="theMode" :value="0">
+                {{ $gettext('Mitgliedschaft ist in mindestens einer dieser Veranstaltungen notwendig') }}
+            </label>
+            <label>
+                <input type="radio" v-model="theMode" :value="1">
+                {{ $gettext('Mitgliedschaft ist in keiner dieser Veranstaltungen erlaubt') }}
+            </label>
+        </section>
+        <section>
+            <label for="csearch">
+                {{ $gettext('Veranstaltung(en)') }}
+            </label>
+            <quicksearch v-if="courseSearch !== null"
+                         :searchtype="courseSearch"
+                         name="course"
+                         :key="NaN"
+                         @input="addCourse"
+                         id="csearch"
+                         ref="courseSearch"></quicksearch>
+            <ul v-if="courseList.length > 0">
+                <li v-for="(course, index) in courseList" :key="index">
+                    {{ course.name }}
+                </li>
+            </ul>
+        </section>
+    </form>
+</template>
+
+<script>
+import {AdmissionRuleMixin} from '../../mixins/AdmissionRuleMixin';
+import ValidityTime from './ValidityTime.vue';
+import quicksearch from '../Quicksearch.vue';
+
+export default {
+    name: 'CourseMemberAdmission',
+    components: { ValidityTime, quicksearch },
+    mixins: [AdmissionRuleMixin],
+    data() {
+        return {
+            messageText: this.message || (
+                this.theMode === 0
+                    ? this.$gettext('Sie sind nicht in einer der gewählten Veranstaltungen eingetragen.')
+                    : this.$gettext('Sie sind bereits in einer der gewählten Veranstaltungen eingetragen.')
+            ),
+            theMode: 0,
+            courseList: [],
+            courseSearch: null
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'CourseMemberAdmission',
+                payload: {
+                    modus: this.theMode,
+                    courses: this.courseList,
+                    message: this.messageText
+                }
+            }
+        }
+    },
+    methods: {
+        addCourse(returnValue, inputValue) {
+            if (!this.courseList.some(i => i.id === returnValue)) {
+                this.courseList.push({id: returnValue, name: inputValue});
+            }
+        },
+        setRuleData(data) {
+            this.courseSearch = data.attributes.payload.search;
+            this.courseList = data.attributes.payload.courses;
+            this.theMode = data.attributes.payload.modus;
+        },
+    },
+    validate() {
+        if (this.courseList.length === 0) {
+            this.invalidData.push(this.$gettext('Bitte geben Sie mindestens eine Veranstaltung an.'));
+        }
+
+        return this.invalidData.length === 0;
+    },
+    mounted() {
+        // Get a new rule instance so we can use quicksearch.
+        if (!this.id || this.id === '') {
+            STUDIP.jsonapi.withPromises().post('admission-rules/CourseMemberAdmission', {
+                data: {
+                    data: {
+                        attributes: {
+                            payload: {
+                                mode: 0,
+                                courses: [],
+                                message: ''
+                            }
+                        }
+                    }
+                }
+            }).then(response => {
+                this.courseSearch = response.data.attributes.payload.search;
+            });
+        }
+    },
+}
+</script>
diff --git a/resources/vue/components/admission/LimitedAdmission.vue b/resources/vue/components/admission/LimitedAdmission.vue
new file mode 100644
index 00000000000..bfa57965330
--- /dev/null
+++ b/resources/vue/components/admission/LimitedAdmission.vue
@@ -0,0 +1,65 @@
+<template>
+    <form class="default">
+        <section>
+            <label for="terms">
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <validity-time></validity-time>
+        <section>
+            <label for="maxnumber">
+                <span class="required">
+                    {{ $gettext('Maximale Anzahl erlaubter Anmeldungen') }}
+                </span>
+                <input type="number" size="4" min="1" v-model="max">
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+import ValidityTime from './ValidityTime.vue';
+
+export default {
+    name: 'LimitedAdmission',
+    components: { ValidityTime },
+    mixins: [AdmissionRuleMixin],
+    props: {
+        maxNumber: {
+            type: Number,
+            default: 1
+        }
+    },
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Sie sind bereits in die maximale Anzahl von %u Veranstaltungen eingetragen.'),
+            max: this.maxNumber
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'LimitedAdmission',
+                payload: {
+                    maxnumber: this.max,
+                    message: this.messageText
+                }
+            }
+        }
+    },
+    methods: {
+        setRuleData(data) {
+            this.max = data.attributes.payload['maxnumber'];
+        },
+        validate() {
+            if (this.max < 1) {
+                this.invalidData.push(this.$gettext('Bitte geben Sie eine gültige Zahl für die Anzahl der maximalen Anmeldungen an.'));
+            }
+
+            return this.invalidData.length === 0;
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/LockedAdmission.vue b/resources/vue/components/admission/LockedAdmission.vue
new file mode 100644
index 00000000000..3e050bed6b7
--- /dev/null
+++ b/resources/vue/components/admission/LockedAdmission.vue
@@ -0,0 +1,42 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+
+export default {
+    name: 'LockedAdmission',
+    mixins: [AdmissionRuleMixin],
+    props: {
+        message: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Die Anmeldung ist gesperrt.'),
+            password1: '',
+            password2: ''
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'LockedAdmission',
+                payload: {
+                    message: this.messageText
+                }
+            }
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/ParticipantRestrictedAdmission.vue b/resources/vue/components/admission/ParticipantRestrictedAdmission.vue
new file mode 100644
index 00000000000..9f75b9a7cce
--- /dev/null
+++ b/resources/vue/components/admission/ParticipantRestrictedAdmission.vue
@@ -0,0 +1,91 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <label>
+            <input type="checkbox" v-model="fcfsEnabled" :disabled="hasPrios">
+            {{ $gettext('Keine automatische Platzverteilung (Windhund-Verfahren)') }}
+            <studip-tooltip-icon v-if="hasPrios"
+                                 :text="$gettext('Es existieren bereits Anmeldungen für die automatische Platzverteilung.')">
+            </studip-tooltip-icon>
+        </label>
+        <section v-if="!fcfsAllowed || !fcfsEnabled">
+            <label>
+                {{ $gettext('Zeitpunkt der automatischen Platzverteilung') }}
+                <datetimepicker v-if="loaded" :value="distributionTime" v-model="distributionTime"></datetimepicker>
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+import Datetimepicker from '../Datetimepicker.vue';
+import StudipTooltipIcon from '../StudipTooltipIcon.vue';
+
+export default {
+    name: 'ParticipantRestrictedAdmission',
+    components: { StudipTooltipIcon, Datetimepicker },
+    mixins: [AdmissionRuleMixin],
+    props: {
+        distribution: {
+            type: Number,
+            default: Math.floor(new Date().getTime() / 1000 + 86400)
+        },
+        fcfs: {
+            type: Boolean,
+            default: true
+        },
+        hasPrios: {
+            type: Boolean,
+            default: false
+        },
+        message: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            messageText: this.message,
+            fcfsAllowed: true,
+            fcfsEnabled: this.distributionTime === 0,
+            distributionTime: this.distribution,
+            loaded: false
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'ParticipantRestrictedAdmission',
+                payload: {
+                    'distribution-time': this.fcfsEnabled ? 0 : this.distributionTime,
+                    'fcfs': this.fcfsEnabled,
+                    'fcfs-allowed': this.fcfsAllowed,
+                    message: this.messageText
+                }
+            }
+        }
+    },
+    methods: {
+        setRuleData(data) {
+            this.fcfsAllowed = data.attributes.payload['fcfs-allowed'];
+            this.distributionTime = data.attributes.payload['distribution-time'] !== 0
+                ? data.attributes.payload['distribution-time']
+                : Math.floor(Date.now() / 1000 + 7 * 86400);
+            this.fcfsEnabled = data.attributes.payload['distribution-time'] === 0;
+            this.loaded = true;
+        }
+    },
+    created() {
+        if (!this.id) {
+            this.distributionTime = Math.floor(new Date().getTime() / 1000 + 86400);
+            this.loaded = true;
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/PasswordAdmission.vue b/resources/vue/components/admission/PasswordAdmission.vue
new file mode 100644
index 00000000000..3f0ecf4099a
--- /dev/null
+++ b/resources/vue/components/admission/PasswordAdmission.vue
@@ -0,0 +1,83 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <section>
+            <studip-message-box v-if="passwordSet" type="warning">
+                {{ $gettext('Es ist bereits ein Passwort eingerichtet. Um es zu überschreiben, geben Sie hier ein neues ein.') }}
+            </studip-message-box>
+            <label>
+                {{ $gettext('Zugangspasswort') }}
+                <input :type="passwordVisible ? 'text' : 'password'" v-model="password1" ref="password1">
+                <studip-icon class="password-visibility" @click="togglePasswordVisible"
+                             :shape="passwordVisible ? 'visibility-invisible' : 'visibility-visible'"></studip-icon>
+            </label>
+            <label>
+                {{ $gettext('Passwort wiederholen') }}
+                <input :type="passwordVisible ? 'text' : 'password'" v-model="password2" ref="password2">
+                <studip-icon class="password-visibility" @click="togglePasswordVisible"
+                             :shape="passwordVisible ? 'visibility-invisible' : 'visibility-visible'"></studip-icon>
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+
+export default {
+    name: 'PasswordAdmission',
+    mixins: [AdmissionRuleMixin],
+    props: {
+        hasPassword: {
+            type: Boolean,
+            default: false
+        }
+    },
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Für die Anmeldung ist ein Passwort erforderlich.'),
+            password1: '',
+            password2: '',
+            passwordVisible: false,
+            passwordSet: this.hasPassword
+        }
+    },
+    methods: {
+        togglePasswordVisible() {
+            this.passwordVisible = !this.passwordVisible;
+        },
+        setRuleData(data) {
+            if (data.attributes.payload.password !== '') {
+                this.passwordSet = true;
+            }
+        },
+        validate() {
+            this.invalidData = [];
+            if (this.password1 === '') {
+                this.invalidData.push(this.$gettext('Das Passwort darf nicht leer sein.'));
+            }
+            if (this.password1 !== this.password2) {
+                this.invalidData.push(this.$gettext('Die eingegebenen Passwörter stimmen nicht überein.'));
+            }
+
+            return this.invalidData.length === 0;
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'PasswordAdmission',
+                payload: {
+                    password: this.password1,
+                    message: this.messageText
+                }
+            }
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/PreferentialAdmission.vue b/resources/vue/components/admission/PreferentialAdmission.vue
new file mode 100644
index 00000000000..76841ec5741
--- /dev/null
+++ b/resources/vue/components/admission/PreferentialAdmission.vue
@@ -0,0 +1,120 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <section>
+            <h3>
+                {{ $gettext('Folgende Personen bei der Platzverteilung bevorzugen:') }}
+            </h3>
+            <div v-if="conditions.length > 0"
+                 role="list">
+                <div v-for="(filter, index) in conditions"
+                     :key="index"
+                     role="listitem">
+                    <p v-if="conditions.length > 1 && index >= 1">
+                        {{ $gettext('oder') }}
+                    </p>
+                    <p v-html="filter.attributes.text"></p>
+                </div>
+            </div>
+            <p v-if="conditions.length === 0">
+                {{ $gettext('Sie haben noch keine Auswahl festgelegt.') }}
+            </p>
+        </section>
+        <section>
+            <button class="button add"
+                    @click.prevent="editFilter">
+                {{ $gettext('Bedingung hinzufügen') }}
+            </button>
+        </section>
+        <section>
+            <label>
+                <input type="checkbox"
+                       v-model="favorSemester"
+                       value="1">
+                {{ $gettext('Höhere Fachsemester bevorzugen') }}
+            </label>
+        </section>
+        <studip-user-filter v-if="showEditFilter"
+                            @submit="confirmDialog"
+                            @close="closeDialog"></studip-user-filter>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+import StudipUserFilter from "../StudipUserFilter.vue";
+
+export default {
+    name: 'PreferentialAdmission',
+    components: { StudipUserFilter },
+    mixins: [AdmissionRuleMixin],
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Folgende Gruppen werden bei der Platzverteilung bevorzugt behandelt: %s'),
+            conditions: [],
+            favorSemester: false,
+            showEditFilter: false,
+            selectedFilters: []
+        }
+    },
+    computed: {
+        groupsAllowed() {
+            return this.assignedRuleTypes.includes('ParticipantRestrictedAdmission')
+        },
+        payload() {
+            return {
+                type: 'PreferentialAdmission',
+                payload: {
+                    conditions: this.conditions,
+                    'favor-semester': this.favorSemester,
+                    message: this.messageText
+                }
+            }
+        }
+    },
+    methods: {
+        editFilter() {
+            this.showEditFilter = true;
+        },
+        closeDialog() {
+            this.showEditFilter = false;
+        },
+        confirmDialog(filter) {
+            STUDIP.jsonapi.withPromises().post(
+                'user-filters',
+                {
+                    data: {
+                        data: {
+                            attributes: {
+                                filters: filter
+                            }
+                        }
+                    }
+                })
+            .then(response => {
+                this.conditions.push(response.data);
+                this.showEditFilter = false;
+            });
+        },
+        setRuleData(data) {
+            this.conditions = data.attributes.payload['conditions'];
+            this.favorSemester = data.attributes.payload['favor-semester'];
+        },
+        validate() {
+            if (this.conditions.length === 0 && !this.favorSemester) {
+                this.invalidData.push(
+                    this.$gettext('Bitte geben Sie mindestens eine Auswahlbedingung an oder '
+                        + 'bevorzugen Sie höhere Fachsemester.')
+                );
+            }
+
+            return this.invalidData.length === 0;
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/TermsAdmission.vue b/resources/vue/components/admission/TermsAdmission.vue
new file mode 100644
index 00000000000..98abcf8fa0b
--- /dev/null
+++ b/resources/vue/components/admission/TermsAdmission.vue
@@ -0,0 +1,57 @@
+<template>
+    <form class="default">
+        <section>
+            <label for="terms">
+                <span class="required">
+                    {{ $gettext('Teilnahmebedingungen') }}
+                </span>
+                <textarea v-model="theTerms" id="terms" rows="4"
+                          :placeholder="$gettext('Formulieren Sie hier die Teilnahmebedingungen.')"></textarea>
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+
+export default {
+    name: 'TermsAdmission',
+    mixins: [AdmissionRuleMixin],
+    props: {
+        terms: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Sie müssen den Teilnahmebedingungen zustimmen.'),
+            theTerms: this.terms
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'TermsAdmission',
+                payload: {
+                    terms: this.theTerms
+                }
+            }
+        }
+    },
+    methods: {
+        setRuleData(data) {
+            this.theTerms = data.attributes.payload.terms;
+        },
+        validate() {
+            this.invalidData = [];
+            if (this.theTerms === '') {
+                this.invalidData.push(this.$gettext('Es sind keine Teilnahmebedingungen angegeben.'));
+            }
+
+            return this.invalidData.length === 0;
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/TimedAdmission.vue b/resources/vue/components/admission/TimedAdmission.vue
new file mode 100644
index 00000000000..ebdc3971e7f
--- /dev/null
+++ b/resources/vue/components/admission/TimedAdmission.vue
@@ -0,0 +1,83 @@
+<template>
+    <form class="default">
+        <section>
+            <label>
+                {{ $gettext('Nachricht bei fehlgeschlagener Anmeldung') }}
+                <textarea name="message" rows="4" cols="50" v-model="messageText"></textarea>
+            </label>
+        </section>
+        <section class="col-3">
+            <label>
+                {{ $gettext('Start des Anmeldezeitraums') }}
+                <datetimepicker v-model="startTime"></datetimepicker>
+            </label>
+        </section>
+        <section class="col-3">
+            <label>
+                {{ $gettext('Ende des Anmeldezeitraums') }}
+                <datetimepicker v-model="endTime"></datetimepicker>
+            </label>
+        </section>
+    </form>
+</template>
+
+<script>
+import { AdmissionRuleMixin } from '../../mixins/AdmissionRuleMixin';
+
+export default {
+    name: 'TimedAdmission',
+    mixins: [ AdmissionRuleMixin ],
+    props: {
+        start: {
+            type: Number,
+            default: 0
+        },
+        end: {
+            type: Number,
+            default: 0
+        },
+        message: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            messageText: this.message || this.$gettext('Die Anmeldung ist nur innerhalb des angegebenen Zeitraums möglich.'),
+            startTime: this.start !== 0 ? this.start : Math.floor(Date.now() / 1000),
+            endTime: this.end !== 0 ? this.end : Math.floor(Date.now() / 1000 + 7 * 86400)
+        }
+    },
+    computed: {
+        payload() {
+            return {
+                type: 'TimedAdmission',
+                payload: {
+                    'starttime': this.startTime,
+                    'endtime': this.endTime,
+                    message: this.messageText
+                }
+            }
+        }
+    },
+    methods: {
+        setRuleData(data) {
+            this.startTime = data.attributes.payload['starttime'];
+            this.endTime = data.attributes.payload['endtime'];
+        },
+        validate() {
+            if (this.startTime < 0) {
+                this.invalidData.push(this.$gettext('Bitte geben Sie eine gültige Startzeit an.'));
+            }
+            if (this.endTime < 0) {
+                this.invalidData.push(this.$gettext('Bitte geben Sie eine gültige Endzeit an.'));
+            }
+            if (this.endTime <= this.startTime) {
+                this.invalidData.push(this.$gettext('Die Endzeit muss nach der Startzeit liegen.'));
+            }
+
+            return this.invalidData.length === 0;
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/admission/ValidityTime.vue b/resources/vue/components/admission/ValidityTime.vue
new file mode 100644
index 00000000000..21c669f3be9
--- /dev/null
+++ b/resources/vue/components/admission/ValidityTime.vue
@@ -0,0 +1,69 @@
+<template>
+    <div>
+        <section>
+            <label>
+                <button class="as-link"
+                   @click.prevent="toggleTime"
+                   :title="configureTime
+                    ? $gettext('Klicken, um diese Regel ab sofort unbegrenzt gelten zu lassen')
+                    : $gettext('Klicken, um einen Zeitraum für die Gültigkeit dieser Regel festzulegen')"
+                >
+                    <studip-icon :shape="configureTime ? 'checkbox-unchecked' : 'checkbox-checked'"></studip-icon>
+                    {{ $gettext('Diese Regel soll ab sofort zeitlich unbegrenzt gelten') }}
+                </button>
+            </label>
+        </section>
+        <section v-if="configureTime" class="col-3">
+            <label>
+                {{ $gettext('Diese Regel gilt von') }}
+                <datetimepicker :value="startTime"></datetimepicker>
+            </label>
+        </section>
+        <section v-if="configureTime" class="col-3">
+            <label>
+                {{ $gettext('bis') }}
+                <datetimepicker :value="endTime"></datetimepicker>
+            </label>
+        </section>
+    </div>
+</template>
+
+<script>
+import Datetimepicker from '../Datetimepicker.vue';
+
+export default {
+    name: 'ValidityTime',
+    components: { Datetimepicker },
+    props: {
+        start: {
+            type: Number,
+            default: 0
+        },
+        end: {
+            type: Number,
+            default: 0
+        }
+    },
+    data() {
+        return {
+            configureTime: this.start !== 0 || this.end !== 0,
+            startTime: this.start !== 0 ? this.start : Math.floor(Date.now() / 1000),
+            endTime: this.end !== 0 ? this.end : Math.floor(Date.now() / 1000 + 7 * 86400)
+        }
+    },
+    methods: {
+        toggleTime() {
+            this.configureTime = !this.configureTime;
+
+            if (this.configureTime) {
+                this.startTime = this.start !== 0 ? this.start : Math.floor(Date.now() / 1000);
+                this.endTime = this.end !== 0 ? this.end : nMath.floor(Date.now() / 1000 + 7 * 86400);
+            } else {
+                this.startTime = 0;
+                this.endTime = 0;
+            }
+
+        }
+    }
+}
+</script>
diff --git a/resources/vue/mixins/AdmissionRuleMixin.js b/resources/vue/mixins/AdmissionRuleMixin.js
new file mode 100644
index 00000000000..0badb1a563f
--- /dev/null
+++ b/resources/vue/mixins/AdmissionRuleMixin.js
@@ -0,0 +1,61 @@
+export const AdmissionRuleMixin = {
+    props: {
+        id: {
+            type: String,
+            default: ''
+        },
+        ruleData: {
+            type: Object,
+            default: null
+        },
+        assignedRuleTypes: {
+            type: Array,
+            default: () => []
+        },
+        message: {
+            type: String,
+            default: ''
+        }
+    },
+    data() {
+        return {
+            theRuleData: this.ruleData,
+            invalidData: []
+        }
+    },
+    methods: {
+        loadRuleData() {
+            STUDIP.jsonapi.withPromises().get('admission-rules/' + this.id)
+                .then((response) => {
+                    this.setRuleData(response.data);
+                });
+        },
+        validate() {
+            return true;
+        },
+        submit() {
+            this.invalidData = [];
+            if (this.validate()) {
+                this.$emit('submit', this.payload);
+            } else {
+                this.$emit('error', this.invalidData);
+            }
+        }
+    },
+    mounted() {
+        if (this.id && this.id !== '' && !this.ruleData) {
+            this.loadRuleData();
+        }
+
+        if (this.ruleData) {
+            this.setRuleData(this.ruleData);
+        }
+
+        STUDIP.eventBus.on('getRuleConfiguration', () => {
+            this.submit();
+        });
+    },
+    beforeDestroy() {
+        STUDIP.eventBus.off('getRuleConfiguration');
+    }
+}
diff --git a/templates/userfilter/display.php b/templates/userfilter/display.php
index 57fdfe64c27..189f6cdfc90 100644
--- a/templates/userfilter/display.php
+++ b/templates/userfilter/display.php
@@ -14,9 +14,10 @@ foreach ($filter->getFields() as $field) {
 }
 if ($filter->show_user_count) {
     $user_count = count($filter->getUsers());
-    $fieldText .= ' ('.sprintf(_('%s Personen'), $user_count);
-    if (!$user_count) {
-        $fieldText .= Icon::create('exclaim-circle', 'attention', ['title' => _("Niemand erfüllt diese Bedingung.")])->asImg();
+    $fieldText .= ' (' . sprintf(ngettext('Eine Person', '%s Personen', $user_count), $user_count);
+    if ($user_count === 0) {
+        $fieldText .= '&nbsp;' . Icon::create('exclaim-circle', Icon::ROLE_ATTENTION)
+                ->asImg(['title' => _('Niemand erfüllt diese Bedingung.')]);
     }
     $fieldText .= ')';
 }
-- 
GitLab