From cac4551262c87b71cb0d1b0767fd5c35731af9a3 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Tue, 11 Jun 2024 20:32:43 +0000
Subject: [PATCH] convert consultation creator to vue and fix several problems,
 fixes #3349

Closes #3349, #1872, #1871, and #3350

Merge request studip/studip!3039
---
 app/controllers/consultation/admin.php        |  11 +-
 .../consultation/consultation_controller.php  |  11 +-
 app/controllers/consultation/overview.php     |   2 +
 app/views/consultation/admin/create.php       | 264 +---------
 app/views/consultation/admin/edit.php         |  10 +-
 app/views/consultation/admin/index.php        |   2 +-
 app/views/consultation/admin/ungrouped.php    |   2 +-
 app/views/consultation/overview/ungrouped.php |  11 +
 ...add_consecutive_flag_for_consultations.php |  45 ++
 lib/classes/JsonApi/RouteMap.php              |   4 +
 .../Consultations/SlotCreationCount.php       | 105 ++++
 lib/models/ConsultationBlock.php              |  11 +-
 lib/models/ConsultationSlot.php               |  45 +-
 resources/assets/javascripts/bootstrap/vue.js |  12 +-
 .../javascripts/lib/RestrictedDatesHelper.ts  |  89 ++++
 resources/assets/javascripts/studip-ui.js     |  84 +--
 resources/assets/stylesheets/scss/forms.scss  |  22 +
 .../vue/components/ConsultationCreator.vue    | 496 ++++++++++++++++++
 resources/vue/components/Datepicker.vue       | 146 ++++--
 .../vue/components/StudipTooltipIcon.vue      |   3 +
 resources/vue/components/Timepicker.vue       |  37 ++
 21 files changed, 1023 insertions(+), 389 deletions(-)
 create mode 100644 db/migrations/6.0.7_add_consecutive_flag_for_consultations.php
 create mode 100644 lib/classes/JsonApi/Routes/Consultations/SlotCreationCount.php
 create mode 100644 resources/assets/javascripts/lib/RestrictedDatesHelper.ts
 create mode 100644 resources/vue/components/ConsultationCreator.vue
 create mode 100644 resources/vue/components/Timepicker.vue

diff --git a/app/controllers/consultation/admin.php b/app/controllers/consultation/admin.php
index bc63a1a4bde..8ee7575ae44 100644
--- a/app/controllers/consultation/admin.php
+++ b/app/controllers/consultation/admin.php
@@ -138,6 +138,7 @@ class Consultation_AdminController extends ConsultationController
 
         $this->room = '';
         $this->responsible = false;
+        $this->slot_count_threshold = self::SLOT_COUNT_THRESHOLD;
 
         // TODO: inst_default?
         if ($this->range instanceof User) {
@@ -155,6 +156,8 @@ class Consultation_AdminController extends ConsultationController
             $block->range = $this->range;
             $this->responsible = $block->getPossibleResponsibilites();
         }
+
+        $this->response->add_header('X-No-Buttons', '');
     }
 
     public function store_action()
@@ -186,7 +189,7 @@ class Consultation_AdminController extends ConsultationController
                 $end,
                 Request::int('day-of-week'),
                 Request::int('interval'),
-                Request::int('duration'),
+                $duration,
                 $pause_time,
                 $pause_duration
             );
@@ -214,6 +217,7 @@ class Consultation_AdminController extends ConsultationController
                 $block->note              = Request::get('note');
                 $block->size              = Request::int('size', 1);
                 $block->lock_time         = Request::int('lock_time');
+                $block->consecutive       = Request::bool('consecutive', false);
 
                 $slots = $block->createSlots(Request::int('duration'), $pause_time, $pause_duration);
                 if (count($slots) === 0) {
@@ -403,6 +407,7 @@ class Consultation_AdminController extends ConsultationController
         $this->block->mail_to_tutors = Request::bool('mail-to-tutors', false);
         $this->block->confirmation_text = trim(Request::get('confirmation-text'));
         $this->block->lock_time = Request::int('lock_time');
+        $this->block->consecutive = Request::bool('consecutive', false);
 
         foreach ($this->block->slots as $slot) {
             $slot->note = '';
@@ -535,7 +540,7 @@ class Consultation_AdminController extends ConsultationController
     public function toggle_action($what, $state, $expired = false)
     {
         if ($what === 'messages') {
-            // TODO: Applicable     everywhere?
+            // TODO: Applicable everywhere?
             $this->getUserConfig()->store(
                 'CONSULTATION_SEND_MESSAGES',
                 (bool) $state
@@ -808,7 +813,7 @@ class Consultation_AdminController extends ConsultationController
             _('Terminblöcke anlegen'),
             $this->createURL(),
             Icon::create('add')
-        )->asDialog('size=auto');
+        )->asDialog('size=big');
         $actions->addLink(
             _('Namen des Reiters ändern'),
             $this->tabURL($action === 'expired'),
diff --git a/app/controllers/consultation/consultation_controller.php b/app/controllers/consultation/consultation_controller.php
index d6927aff181..00e10ad15e4 100644
--- a/app/controllers/consultation/consultation_controller.php
+++ b/app/controllers/consultation/consultation_controller.php
@@ -19,7 +19,7 @@ abstract class ConsultationController extends AuthenticatedController
             $this->range = Context::get();
             $type = 'object';
         } else {
-            $this->range = $GLOBALS['user']->getAuthenticatedUser();
+            $this->range = User::findCurrent();
         }
 
         if (!$this->range) {
@@ -60,7 +60,7 @@ abstract class ConsultationController extends AuthenticatedController
         $this->render_template('consultation/not_found', $this->layout);
     }
 
-    protected function activateNavigation($path)
+    protected function activateNavigation($path): void
     {
         $path = ltrim($path, '/');
 
@@ -73,7 +73,7 @@ abstract class ConsultationController extends AuthenticatedController
         }
     }
 
-    protected function getConsultationTitle()
+    protected function getConsultationTitle(): string
     {
         return $this->range->getConfiguration()->CONSULTATION_TAB_TITLE;
     }
@@ -103,7 +103,8 @@ abstract class ConsultationController extends AuthenticatedController
         return $block;
     }
 
-    protected function loadSlot($block_id, $slot_id)
+
+    protected function loadSlot($block_id, $slot_id): ConsultationSlot
     {
         $block = $this->loadBlock($block_id);
         $slot  = $block->slots->find($slot_id);
@@ -115,7 +116,7 @@ abstract class ConsultationController extends AuthenticatedController
         return $slot;
     }
 
-    protected function loadBooking($block_id, $slot_id, $booking_id)
+    protected function loadBooking($block_id, $slot_id, $booking_id): ConsultationBooking
     {
         $slot    = $this->loadSlot($block_id, $slot_id);
         $booking = $slot->bookings->find($booking_id);
diff --git a/app/controllers/consultation/overview.php b/app/controllers/consultation/overview.php
index ce6cd316b0e..2afb710df13 100644
--- a/app/controllers/consultation/overview.php
+++ b/app/controllers/consultation/overview.php
@@ -71,6 +71,8 @@ class Consultation_OverviewController extends ConsultationController
 
             if ($this->slot->isOccupied()) {
                 PageLayout::postError(_('Dieser Termin ist bereits belegt.'));
+            } elseif (!$this->slot->isBookable()) {
+                PageLayout::postError(_('Dieser Termin ist für Buchungen gesperrt.'));
             } else {
                 $booking = new ConsultationBooking();
                 $booking->slot_id = $this->slot->id;
diff --git a/app/views/consultation/admin/create.php b/app/views/consultation/admin/create.php
index 3da70e064d8..34385b500d2 100644
--- a/app/views/consultation/admin/create.php
+++ b/app/views/consultation/admin/create.php
@@ -5,248 +5,32 @@
  * @var string|null $room
  * @var array $responsible
  * @var Range $range
+ * @var int $slot_count_threshold
  */
 
-$days_of_the_week = [
-    _('Montag')     => 1,
-    _('Dienstag')   => 2,
-    _('Mittwoch')   => 3,
-    _('Donnerstag') => 4,
-    _('Freitag')    => 5,
-    _('Samstag')    => 6,
-    _('Sonntag')    => 0
-];
-$intervals = [
-    _('wöchentlich')     => 1,
-    _('zweiwöchentlich') => 2,
-    _('dreiwöchentlich') => 3,
-    _('monatlich')       => 4,
-];
-?>
-
-<form action="<?= $controller->store() ?>" method="post" class="default" data-dialog>
-    <?= CSRFProtection::tokenTag() ?>
-
-<? if ($flash['confirm-many']): ?>
-    <?= MessageBox::info(
-        _('Sie erstellen eine sehr große Anzahl an Terminen.') . ' ' .
-        _('Bitte bestätigen Sie diese Aktion.'),
-        [
-            '<label><input type="checkbox" name="confirmed" value="1">' .
-            sprintf(
-                _('Ja, ich möchte wirklich %s Termine erstellen.'),
-                number_format($flash['confirm-many'], 0, ',', '.')
-            ) .
-            '</label>'
-        ]
-    )->hideClose() ?>
-<? endif; ?>
-
-    <fieldset>
-        <legend>
-            <?= _('Ort und Zeit') ?>
-        </legend>
-
-        <label>
-            <span class="required"><?= _('Ort') ?></span>
-
-            <input required type="text" name="room"
-                   value="<?= htmlReady(Request::get('room', $room)) ?>"
-                   placeholder="<?= _('Ort') ?>">
-        </label>
-
-        <label class="col-3">
-            <span class="required"><?= _('Beginn') ?></span>
-
-            <input required type="text" name="start-date" id="start-date"
-                   value="<?= htmlReady(Request::get('start-date', strftime('%d.%m.%Y', strtotime('+7 days'))))  ?>"
-                   placeholder="<?= _('tt.mm.jjjj') ?>"
-                   data-date-picker='{">=":"today","disable_holidays": true}'>
-        </label>
-
-        <label class="col-3">
-            <span class="required"><?= _('Ende') ?></span>
-
-            <input required type="text" name="end-date" id="end-date"
-                   value="<?= htmlReady(Request::get('end-date', strftime('%d.%m.%Y', strtotime('+4 weeks'))))  ?>"
-                   placeholder="<?= _('tt.mm.jjjj') ?>"
-                   data-date-picker='{">=":"#start-date","disable_holidays": true}'>
-        </label>
-
-        <label class="col-3">
-            <span class="required"><?= _('Am Wochentag') ?></span>
-
-            <select required name="day-of-week">
-            <? foreach ($days_of_the_week as $day => $value): ?>
-                <option value="<?= $value ?>" <? if (Request::get('day-of-week', strftime('%w')) == $value) echo 'selected'; ?>>
-                    <?= htmlReady($day) ?>
-                </option>
-            <? endforeach; ?>
-            </select>
-        </label>
-
-        <label class="col-3">
-            <span class="required"><?= _('Intervall') ?></span>
-            <select required name="interval">
-            <? foreach ($intervals as $interval => $value): ?>
-                <option value="<?= $value ?>" <? if (Request::int('interval') == $value) echo 'selected'; ?>>
-                    <?= htmlReady($interval) ?>
-                </option>
-            <? endforeach; ?>
-            </select>
-        </label>
-
-        <label for="start-time" class="col-3">
-            <span class="required"><?= _('Von') ?></span>
-
-            <input required type="text" name="start-time" id="start-time"
-                   value="<?= htmlReady(Request::get('start-time', '08:00')) ?>"
-                   placeholder="<?= _('HH:mm') ?>"
-                   data-time-picker='{"<":"#end-time"}'>
-        </label>
-
-        <label for="ende_hour" class="col-3">
-            <span class="required"><?= _('Bis') ?></span>
-
-            <input required type="text" name="end-time" id="end-time"
-                   value="<?= htmlReady(Request::get('end-time', '09:00')) ?>"
-                   placeholder="<?= _('HH:mm') ?>"
-                   data-time-picker='{">":"#start-time"}'>
-        </label>
-
-        <label class="col-3">
-            <span class="required"><?= _('Dauer eines Termins in Minuten') ?></span>
-            <input required type="text" name="duration"
-                   value="<?= htmlReady(Request::int('duration', 15)) ?>"
-                   maxlength="3" pattern="^\d+$">
-        </label>
+$convertResponsibilities = function ($input) {
+    if ($input === false) {
+        return json_encode(false);
+    }
 
-        <label class="col-3">
-            <?= _('Maximale Teilnehmerzahl') ?>
-            <?= tooltipIcon(_('Falls Sie mehrere Personen zulassen wollen (wie z.B. zu einer Klausureinsicht), so geben Sie hier die maximale Anzahl an Personen an, die sich anmelden dürfen.')) ?>
-            <input required type="text" name="size" id="size"
-                   min="1" max="50" value="<?= Request::int('size', 1) ?>">
-        </label>
+    foreach ($input as $key => $values) {
+        $input[$key] = array_map(
+            fn($item) => ['id' => $item->id, 'label' => $item instanceof Statusgruppen ? $item->getName() : $item->getFullName()],
+            $values
+        );
+    }
 
-        <label>
-            <input type="checkbox" name="pause" value="1"
-                   data-shows=".pause-inputs" data-activates=".pause-inputs input"
-                    <? if (Request::bool('pause')) echo 'checked'; ?>>
-            <?= _('Pausen zwischen den Terminen einfügen?') ?>
-        </label>
+    return json_encode($input);
+}
 
-        <label class="col-3 pause-inputs">
-            <?= _('Eine Pause nach wie vielen Minuten einfügen?') ?>
-            <input type="number" name="pause_time"
-                   value="<?= htmlReady(Request::int('pause_time', 45)) ?>"
-                   min="1">
-        </label>
-
-        <label class="col-3 pause-inputs">
-            <?= _('Dauer der Pause in Minuten') ?>
-            <input type="number" name="pause_duration"
-                   value="<?= Request::int('pause_duration', 15) ?>"
-                   min="1">
-        </label>
-
-        <label>
-            <input type="checkbox" name="lock" value="1" data-shows=".lock-inputs" data-activates=".lock-inputs input">
-            <?= _('Termine für Buchungen sperren?') ?>
-        </label>
-
-        <label class="lock-inputs">
-            <?= _('Wieviele Stunden vor Beginn des Blocks sollen die Termine für Buchungen gesperrt werden?') ?>
-            <input type="number" name="lock_time"
-                   value="<?= htmlReady(Request::int('lock_time', 24)) ?>"
-                   min="1">
-        </label>
-    </fieldset>
-
-<? if ($responsible): ?>
-    <fieldset>
-        <legend><?= _('Durchführende Personen, Gruppen oder Einrichtungen') ?></legend>
-
-    <? if ($range instanceof Institute): ?>
-        <p>
-            <?= _('Bei Einrichtungen muss mindestens eine durchführende Person, Gruppe oder Einrichtung zugewiesen '
-                . 'werden.') ?>
-        </p>
-        <p>
-            <?= _('Bitte beachten Sie, dass bei Zuweisungen von Statusgruppen alle Personen der Gruppe mit dem Status '
-                . '"tutor" und "dozent" als durchführende Personen zugewiesen werden und über alle Buchungen '
-                . 'informiert werden.') ?>
-            <?= _('Gleiches gilt für eine zugewiesene Einrichtung. Bitte achten Sie darauf, dass Sie Ihre hier '
-                . ' getroffene Auswahl in Absprache tätigen.') ?>
-        </p>
-    <? endif; ?>
-
-        <?= $this->render_partial('consultation/admin/block-responsibilities.php', compact('responsible')) ?>
-    </fieldset>
-<? endif; ?>
-
-    <fieldset>
-        <legend><?= _('Weitere Einstellungen') ?></legend>
-
-        <label>
-            <?= _('Information zu den Terminen in diesem Block') ?>
-            <textarea name="note"><?= htmlReady(Request::get('note')) ?></textarea>
-        </label>
-
-        <label>
-            <input type="checkbox" name="calender-events" value="1"
-                    <? if (Request::bool('calender-events')) echo 'checked'; ?>>
-            <?= _('Die freien Termine auch im Kalender markieren') ?>
-        </label>
-
-    <? if ($range instanceof Course): ?>
-        <label>
-            <input type="checkbox" name="mail-to-tutors" value="1" checked>
-            <?= _('Tutor/innen beim Versand von Buchungsbenachrichtigungen berücksichtigen?') ?>
-        </label>
-    <? endif; ?>
-
-        <label>
-            <input type="checkbox" name="show-participants" value="1"
-                    <? if (Request::bool('show-participants')) echo 'checked'; ?>>
-            <?= _('Namen der buchenden Personen sind öffentlich sichtbar') ?>
-        </label>
-
-        <label>
-            <?= _('Grund der Buchung abfragen') ?>
-        </label>
-        <div class="hgroup">
-            <label>
-                <input type="radio" name="require-reason" value="yes"
-                       <? if (Request::get('require-reason') === 'yes') echo 'checked'; ?>>
-                <?= _('Ja, zwingend erforderlich') ?>
-            </label>
-
-            <label>
-                <input type="radio" name="require-reason" value="optional"
-                       <? if (Request::get('require-reason', 'optional') === 'optional') echo 'checked'; ?>>
-                <?= _('Ja, optional') ?>
-            </label>
-
-            <label>
-                <input type="radio" name="require-reason" value="no"
-                       <? if (Request::get('require-reason') === 'no') echo 'checked'; ?>>
-                <?= _('Nein') ?>
-            </label>
-        </div>
-
-        <label>
-            <?= _('Bestätigung für folgenden Text einholen') ?>
-            (<?= _('optional') ?>)
-            <?= tooltipIcon(_('Wird hier ein Text eingegeben, so müssen Buchende bestätigen, dass sie diesen Text gelesen haben.')) ?>
-            <textarea name="confirmation-text"><?= htmlReady(Request::get('confirmation-text')) ?></textarea>
-        </label>
-    </fieldset>
-
-    <footer data-dialog-button>
-        <?= Studip\Button::createAccept(_('Termin speichern')) ?>
-        <?= Studip\LinkButton::createCancel(
-            _('Abbrechen'),
-            $controller->indexURL()
-        ) ?>
-    </footer>
-</form>
+?>
+<div data-vue-app="<?= htmlReady(json_encode(['components' => ['ConsultationCreator']])) ?>"
+     is="ConsultationCreator"
+     cancel-url="<?= $controller->indexURL() ?>"
+     store-url="<?= $controller->storeURL() ?>"
+     :with-responsible="<?= htmlReady($convertResponsibilities($responsible)) ?>"
+     range-type="<?= get_class($range) ?>"
+     default-room="<?= htmlReady($room) ?>"
+     :slot-count-threshold="<?= htmlReady($slot_count_threshold) ?>"
+     :as-dialog="<?= json_encode(Request::isXhr()) ?>"
+></div>
diff --git a/app/views/consultation/admin/edit.php b/app/views/consultation/admin/edit.php
index 347a05a2f22..f9b416a982c 100644
--- a/app/views/consultation/admin/edit.php
+++ b/app/views/consultation/admin/edit.php
@@ -41,8 +41,8 @@
         </label>
 
         <? if ($responsible): ?>
-        <?= $this->render_partial('consultation/admin/block-responsibilities.php', compact('responsible', 'block')) ?>
-    <? endif; ?>
+            <?= $this->render_partial('consultation/admin/block-responsibilities.php', compact('responsible', 'block')) ?>
+        <? endif; ?>
 
         <label>
             <?= _('Maximale Teilnehmerzahl') ?>
@@ -71,6 +71,12 @@
             <?= _('Namen der buchenden Personen sind öffentlich sichtbar') ?>
         </label>
 
+        <label>
+            <input type="checkbox" name="consecutive" value="1"
+                   <? if ($block->consecutive) echo 'checked'; ?>>
+            <?= _('Termine innerhalb dieses Blocks nur fortlaufend vergeben') ?>
+        </label>
+
         <label>
             <?= _('Grund der Buchung abfragen') ?>
         </label>
diff --git a/app/views/consultation/admin/index.php b/app/views/consultation/admin/index.php
index 52a352cd213..43cd5bf94ec 100644
--- a/app/views/consultation/admin/index.php
+++ b/app/views/consultation/admin/index.php
@@ -13,7 +13,7 @@
 <?= MessageBox::info(sprintf(
     implode('<br>', [
         _('Derzeit sind keine Termine eingetragen.'),
-        '<a href="%s" class="button" data-dialog="size=auto">%s</a>',
+        '<a href="%s" class="button" data-dialog="size=big">%s</a>',
     ]),
     $controller->create(),
     _('Terminblöcke anlegen')
diff --git a/app/views/consultation/admin/ungrouped.php b/app/views/consultation/admin/ungrouped.php
index f21dc6ae06a..1796e31331d 100644
--- a/app/views/consultation/admin/ungrouped.php
+++ b/app/views/consultation/admin/ungrouped.php
@@ -14,7 +14,7 @@
 <?= MessageBox::info(sprintf(
     implode('<br>', [
         _('Derzeit sind keine Termine eingetragen.'),
-        '<a href="%s" class="button" data-dialog="size=auto">%s</a>',
+        '<a href="%s" class="button" data-dialog="size=big">%s</a>',
     ]),
     $controller->create(),
     _('Terminblöcke anlegen')
diff --git a/app/views/consultation/overview/ungrouped.php b/app/views/consultation/overview/ungrouped.php
index b4d5c62b9f0..147f58858ae 100644
--- a/app/views/consultation/overview/ungrouped.php
+++ b/app/views/consultation/overview/ungrouped.php
@@ -1,3 +1,14 @@
+<?php
+/**
+ * @var ConsultationBlock[] $blocks
+ * @var Consultation_OverviewController $controller
+ * @var int $count
+ * @var int $limit
+ * @var int $page
+ *
+ * @var callable $displayNote
+ */
+?>
 <? if (count($blocks) === 0): ?>
 
 <?= MessageBox::info(_('Aktuell werden keine Termine angeboten.'))->hideClose() ?>
diff --git a/db/migrations/6.0.7_add_consecutive_flag_for_consultations.php b/db/migrations/6.0.7_add_consecutive_flag_for_consultations.php
new file mode 100644
index 00000000000..52b623da83e
--- /dev/null
+++ b/db/migrations/6.0.7_add_consecutive_flag_for_consultations.php
@@ -0,0 +1,45 @@
+<?php
+return new class extends Migration
+{
+    public function description()
+    {
+        return 'Adds new flag "consecutive" to table "consultation_blocks" and stores previous slot information per slot';
+    }
+
+    protected function up()
+    {
+        $query = "ALTER TABLE `consultation_blocks`
+                  ADD COLUMN `consecutive` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0' AFTER `lock_time`";
+        DBManager::get()->exec($query);
+
+        $query = "ALTER TABLE `consultation_slots`
+                  ADD COLUMN `previous_slot_id` INT(11) UNSIGNED DEFAULT NULL AFTER `block_id`";
+        DBManager::get()->exec($query);
+
+        // THis will set the previous slot relation for all slots
+        $query = "UPDATE consultation_slots AS s0
+                  JOIN consultation_slots AS s1
+                    ON s1.slot_id = (
+                      SELECT slot_id
+                      FROM consultation_slots AS s2
+                      WHERE s2.block_id = s0.block_id
+                        AND s2.start_time < s0.start_time
+                        AND s2.slot_id != s0.slot_id
+                      ORDER BY s2.start_time DESC
+                      LIMIT 1
+                    )
+                  SET s0.previous_slot_id = s1.slot_id";
+        DBManager::get()->exec($query);
+    }
+
+    protected function down()
+    {
+        $query = "ALTER TABLE `consultation_slots`
+                  DROP COLUMN `previous_slot_id`";
+        DBManager::get()->exec($query);
+
+        $query = "ALTER TABLE `consultation_blocks`
+                  DROP COLUMN `consecutive`";
+        DBManager::get()->exec($query);
+    }
+};
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 149f681dcc7..63c69e63059 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -5,6 +5,7 @@ namespace JsonApi;
 use JsonApi\Contracts\JsonApiPlugin;
 use JsonApi\Middlewares\Authentication;
 use JsonApi\Middlewares\DangerousRouteHandler;
+use JsonApi\Routes\Consultations\SlotCreationCount;
 use JsonApi\Routes\Holidays\HolidaysShow;
 use Slim\Routing\RouteCollectorProxy;
 
@@ -220,6 +221,9 @@ class RouteMap
 
     private function addAuthenticatedConsultationRoutes(RouteCollectorProxy $group): void
     {
+        // TODO: I know, not very JSONAPI-like but it's a NonJsonApiController ¯\_(ツ)_/¯
+        $group->get('/consultation-slots/count', SlotCreationCount::class);
+
         $group->get('/{type:courses|institutes|users}/{id}/consultations', Routes\Consultations\BlocksByRangeIndex::class);
 
         $group->get('/consultation-blocks/{id}', Routes\Consultations\BlockShow::class);
diff --git a/lib/classes/JsonApi/Routes/Consultations/SlotCreationCount.php b/lib/classes/JsonApi/Routes/Consultations/SlotCreationCount.php
new file mode 100644
index 00000000000..c378771c6aa
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Consultations/SlotCreationCount.php
@@ -0,0 +1,105 @@
+<?php
+namespace JsonApi\Routes\Consultations;
+
+use ConsultationBlock;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\NonJsonApiController;
+use Neomerx\JsonApi\Exceptions\JsonApiException;
+use Neomerx\JsonApi\Schema\ErrorCollection;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+final class SlotCreationCount extends NonJsonApiController
+{
+    public function __invoke(Request $request, Response $response, array $args)
+    {
+        $parameters = $request->getQueryParams();
+
+        $this->validateParameters($parameters);
+
+        // Determine duration of a slot and pause times
+        $slot_count = ConsultationBlock::countSlots(
+            strtotime($parameters['start']),
+            strtotime($parameters['end']),
+            $parameters['dow'],
+            $parameters['interval'],
+            $parameters['duration'],
+            $parameters['pause_time'] ?? null,
+            $parameters['pause_duration'] ?? null
+        );
+
+        $response->getBody()->write((string) $slot_count);
+        return $response->withAddedHeader('Content-Type', 'application/json');
+    }
+
+    private function validateParameters(array $parameters): void
+    {
+        $collection = new ErrorCollection();
+
+        foreach (['start', 'end', 'dow', 'interval', 'duration'] as $key) {
+            if (!isset($parameters[$key])) {
+                $collection->addQueryParameterError($key, 'Parameter is missing');
+            }
+        }
+
+        if (isset($parameters['start'], $parameters['end'])) {
+            $start = strtotime($parameters['start']);
+            $end = strtotime($parameters['end']);
+
+            if (!$start) {
+                $collection->addQueryParameterError('start', 'Parameter has invalid datetime format');
+            }
+
+            if (!$end) {
+                $collection->addQueryParameterError('end', 'Parameter has invalid datetime format');
+            }
+
+            if ($start && $end && $start > $end) {
+                $collection->addQueryParameterError('start', 'Datetime value of start must be before end');
+            }
+        }
+
+        if (
+            isset($parameters['dow'])
+            && (
+                !ctype_digit($parameters['dow'])
+                || $parameters['dow'] < 0
+                || $parameters['dow'] > 6
+            )
+        ) {
+            $collection->addQueryParameterError('dow', 'Parameter must be a number between 0 and 6');
+        }
+
+        if (
+            isset($parameters['interval'])
+            && (
+                !ctype_digit($parameters['interval'])
+                || $parameters['interval'] < 0
+                || $parameters['interval'] > 4
+            )
+        ) {
+            $collection->addQueryParameterError('interval', 'Parameter must be a number between 0 and 4');
+        }
+
+        if (
+            isset($parameters['duration'])
+            && (
+                !ctype_digit($parameters['duration'])
+                || $parameters['duration'] <= 0
+            )
+        ) {
+            $collection->addQueryParameterError('duration', 'Parameter must be a positive number');
+        }
+
+        if (
+            isset($parameters['pause_time'], $parameters['duration'])
+            && $parameters['pause_time'] < $parameters['duration']
+        ) {
+            $collection->addQueryParameterError('pause_time', 'The defined time to a pause is shorter than the duration of a slot.');
+        }
+
+        if (count($collection) > 0) {
+            throw new JsonApiException($collection);
+        }
+    }
+}
diff --git a/lib/models/ConsultationBlock.php b/lib/models/ConsultationBlock.php
index 9113547cffe..cde8f7707c9 100644
--- a/lib/models/ConsultationBlock.php
+++ b/lib/models/ConsultationBlock.php
@@ -25,6 +25,7 @@
  * @property string $note database column
  * @property int $size database column
  * @property int|null $lock_time database column
+ * @property bool $consecutive database column
  * @property int $mkdate database column
  * @property int $chdate database column
  * @property SimpleORMapCollection|ConsultationSlot[] $slots has_many ConsultationSlot
@@ -211,6 +212,10 @@ class ConsultationBlock extends SimpleORMap implements PrivacyObject
                 );
             }
 
+            if (!$interval) {
+                break;
+            }
+
             $current = strtotime("+{$interval} weeks", $current);
         }
 
@@ -274,9 +279,9 @@ class ConsultationBlock extends SimpleORMap implements PrivacyObject
             }
 
             $slots[] = ConsultationSlot::build([
-                'block_id'   => $this->id,
-                'start_time' => $now,
-                'end_time'   => strtotime("+{$duration} minutes", $now),
+                'block_id'      => $this->id,
+                'start_time'    => $now,
+                'end_time'      => strtotime("+{$duration} minutes", $now),
             ]);
 
             $now = strtotime("+{$duration} minutes", $now);
diff --git a/lib/models/ConsultationSlot.php b/lib/models/ConsultationSlot.php
index 0da02df993c..c46e47c1e64 100644
--- a/lib/models/ConsultationSlot.php
+++ b/lib/models/ConsultationSlot.php
@@ -9,6 +9,7 @@
  * @property int $id alias column for slot_id
  * @property int $slot_id database column
  * @property int $block_id database column
+ * @property int $previous_slot_id database column
  * @property int $start_time database column
  * @property int $end_time database column
  * @property string $note database column
@@ -17,6 +18,7 @@
  * @property SimpleORMapCollection|ConsultationBooking[] $bookings has_many ConsultationBooking
  * @property SimpleORMapCollection|ConsultationEvent[] $events has_many ConsultationEvent
  * @property ConsultationBlock $block belongs_to ConsultationBlock
+ * @property ConsultationSlot|null $previous_slot has_one ConsultationSlot
  * @property-read mixed $has_bookings additional field
  * @property-read mixed $is_expired additional field
  */
@@ -45,15 +47,36 @@ class ConsultationSlot extends SimpleORMap
             'assoc_foreign_key' => 'slot_id',
             'on_delete'         => 'delete',
         ];
+        $config['has_one']['previous_slot'] = [
+            'class_name'        => ConsultationSlot::class,
+            'foreign_key'       => 'previous_slot_id',
+        ];
 
         $config['registered_callbacks']['before_create'][] = function (ConsultationSlot $slot) {
             $slot->updateEvents();
         };
+        $config['registered_callbacks']['before_store'][] = function (ConsultationSlot $slot) {
+            $previous = static::findOneBySQL(
+                "block_id = ? AND start_time < ? ORDER BY start_time DESC",
+                [$slot->block_id, $slot->start_time]
+            );
+            $slot->previous_slot_id = $previous?->id;
+        };
         $config['registered_callbacks']['after_delete'][] = function ($slot) {
             $block = $slot->block;
             if ($block && count($block->slots) === 0) {
                 $block->delete();
             }
+
+            // Close gap
+            self::findEachBySQL(
+                function (ConsultationSlot $s) use ($slot) {
+                    $s->previous_slot_id = $slot->previous_slot_id;
+                    $s->store();
+                },
+                'previous_slot_id = ?',
+                [$slot->id]
+            );
         };
 
         $config['additional_fields']['has_bookings']['get'] = function ($slot): bool {
@@ -139,10 +162,6 @@ class ConsultationSlot extends SimpleORMap
 
     /**
      * Returns whether this slot is occupied (by a given user).
-     *
-     * @param  mixed $user_id Id of the user (optional)
-     * @return boolean indicating whether the slot is occupied (by the given
-     *                 user)
      */
     public function isOccupied($user_id = null)
     {
@@ -154,7 +173,6 @@ class ConsultationSlot extends SimpleORMap
     /**
      * Returns whether the slot is locked for bookings.
      *
-     * @return bool
      */
     public function isLocked(): bool
     {
@@ -162,6 +180,20 @@ class ConsultationSlot extends SimpleORMap
             && strtotime("-{$this->block->lock_time} hours", $this->block->start) < time();
     }
 
+    /**
+     * Returns whether the slot is bookable for the given user_id
+     */
+    public function isBookable(?string $user_id = null): bool
+    {
+        return !$this->isOccupied($user_id)
+            && !$this->isLocked()
+            && !(
+                $this->block->consecutive
+                && $this->previous_slot
+                && !$this->previous_slot->isOccupied()
+            );
+    }
+
     /**
      * Creates a Stud.IP calendar event relating to the slot.
      *
@@ -306,8 +338,7 @@ class ConsultationSlot extends SimpleORMap
         $user = $user ?? User::findCurrent();
 
         return ConsultationBooking::userMayCreateBookingForRange($this->block->range, $user)
-            && !$this->isOccupied()
-            && !$this->isLocked();
+            && $this->isBookable($user->id);
     }
 
 
diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js
index b8c938d2904..c6816a21a38 100644
--- a/resources/assets/javascripts/bootstrap/vue.js
+++ b/resources/assets/javascripts/bootstrap/vue.js
@@ -28,27 +28,17 @@ STUDIP.ready(() => {
         });
 
         STUDIP.Vue.load().then(async ({createApp, store}) => {
-            let vm;
             if (config.store) {
                 const storeConfig = await import(`../../../vue/store/${config.store}.js`);
-                console.log('store', storeConfig.default);
 
                 store.registerModule(config.id, storeConfig.default, {root: true});
 
                 Object.keys(data).forEach(command => {
                     store.commit(`${config.id}/${command}`, data[command]);
                 });
-                vm = createApp({components});
-            } else {
-                vm = createApp({data, components});
             }
-            // import myCoursesStore from '../stores/MyCoursesStore.js';
-            //
-            // myCoursesStore.namespaced = true;
-            //
-            // store.registerModule('my-courses', myCoursesStore);
 
-            vm.$mount(this);
+            createApp({components, data}).$mount(this);
         });
 
         $(this).attr('data-vue-app-created', '');
diff --git a/resources/assets/javascripts/lib/RestrictedDatesHelper.ts b/resources/assets/javascripts/lib/RestrictedDatesHelper.ts
new file mode 100644
index 00000000000..bcc0af2eb5d
--- /dev/null
+++ b/resources/assets/javascripts/lib/RestrictedDatesHelper.ts
@@ -0,0 +1,89 @@
+import { jsonapi } from "./jsonapi";
+
+type RestrictedDate = {
+    year: Number,
+    month: Number,
+    day: Number,
+
+    reason: string | null,
+    lock: boolean
+}
+
+class RestrictedDatesHelper
+{
+    static #loadedYears : Number[] = [];
+    static #restrictedDates: RestrictedDate[] = [];
+
+    static isDateRestricted(date: Date, returnBoolean: Boolean = false): RestrictedDate | Boolean {
+        const restrictedDate : RestrictedDate | undefined = this.#restrictedDates.find(item => {
+            return item.year === date.getFullYear()
+                && item.month === date.getMonth() + 1
+                && item.day === date.getDate();
+        });
+
+        if (returnBoolean) {
+            return !!restrictedDate;
+        }
+
+        return restrictedDate ?? this.#convertDate(date, null, false);
+    }
+
+    static async loadRestrictedDatesByYear(year: Number): Promise<void> {
+        if (this.#loadedYears.includes(year)) {
+            return Promise.reject();
+        }
+
+        this.#loadedYears.push(year);
+
+        jsonapi.withPromises().request('holidays', {data: {
+            'filter[year]': year
+        }}).then((response: [] | Object) => {
+            // Since PHP will return an empty object as an array,
+            // we need to check
+            if (Array.isArray(response)) {
+                return;
+            }
+
+            for (const [date, data] of Object.entries(response)) {
+                this.#addRestrictedDate(
+                    new Date(date),
+                    data.holiday,
+                    data.mandatory
+                );
+            }
+        });
+    }
+
+    static #addRestrictedDate(date: Date, reason: string, lock: boolean = true): void {
+        const restricted = this.#convertDate(date, reason, lock);
+
+        this.#restrictedDates = this.#restrictedDates.filter(item => {
+            return item.year !== restricted.year
+                || item.month !== restricted.month
+                || item.day !== restricted.day;
+        });
+
+        this.#restrictedDates.push(restricted);
+    }
+
+    static removeRestrictedDate(date: Date): void {
+        this.#restrictedDates = this.#restrictedDates.filter(item => {
+            return item.year !== date.getFullYear()
+                || item.month !== date.getMonth() + 1
+                || item.day !== date.getDate();
+        });
+    }
+
+    static #convertDate(date: Date, reason: string | null, lock: boolean): RestrictedDate {
+        return {
+            year: date.getFullYear(),
+            month: date.getMonth() + 1,
+            day: date.getDate(),
+
+            reason,
+            lock
+        };
+    }
+}
+
+export default RestrictedDatesHelper;
diff --git a/resources/assets/javascripts/studip-ui.js b/resources/assets/javascripts/studip-ui.js
index e611150f7b2..f98ba946972 100644
--- a/resources/assets/javascripts/studip-ui.js
+++ b/resources/assets/javascripts/studip-ui.js
@@ -1,5 +1,6 @@
 import { $gettext } from './lib/gettext';
 import eventBus from "./lib/event-bus.ts";
+import RestrictedDatesHelper from './lib/RestrictedDatesHelper';
 
 /**
  * This file contains extensions/adjustments for jQuery UI.
@@ -28,33 +29,11 @@ import eventBus from "./lib/event-bus.ts";
     }
 
     function disableHolidaysBeforeShow(date) {
-        const year = date.getFullYear();
-
-        if (STUDIP.UI.restrictedDates[year] === undefined) {
-            STUDIP.UI.restrictedDates[year] = {};
-
-            STUDIP.jsonapi.withPromises().get('holidays', {data: {
-                'filter[year]': year
-            }}).then(response => {
-                // Since PHP will return an empty object as an array,
-                // we need to check
-                if (Array.isArray(response)) {
-                    return;
-                }
-
-                for (const [date, data] of Object.entries(response)) {
-                    STUDIP.UI.addRestrictedDate(
-                        new Date(date),
-                        data.holiday,
-                        data.mandatory
-                    );
-                }
-
-                $(this).datepicker('refresh');
-            });
-        }
-
-        const {reason, lock} = STUDIP.UI.isDateRestricted(date, false);
+        RestrictedDatesHelper.loadRestrictedDatesByYear(date.getFullYear()).then(
+            () => $(this).datepicker('refresh'),
+            () => null
+        );
+        const {reason, lock} = RestrictedDatesHelper.isDateRestricted(date);
         return [!lock, lock ? 'ui-datepicker-is-locked' : null, reason];
     }
 
@@ -83,57 +62,8 @@ import eventBus from "./lib/event-bus.ts";
         return;
     }
 
+    STUDIP.UI = {};
     // Setup Stud.IP's own datepicker extensions
-    STUDIP.UI = Object.assign(STUDIP.UI || {}, {
-        restrictedDates: {},
-        addRestrictedDate(date, reason = '', lock = true) {
-            if (this.isDateRestricted(date)) {
-                return;
-            }
-
-            const [year, month, day] = this.convertDateForRestriction(date);
-            if (this.restrictedDates[year] === undefined) {
-                this.restrictedDates[year] = {};
-            }
-            if (this.restrictedDates[year][month] === undefined) {
-                this.restrictedDates[year][month] = {};
-            }
-
-            this.restrictedDates[year][month][day] = {reason, lock};
-        },
-        removeRestrictedDate(date) {
-            if (!this.isDateRestricted(date)) {
-                return false;
-            }
-            const [year, month, day] = this.convertDateForRestriction(date);
-
-            delete this.restrictedDates[year][month][day];
-
-            if (Object.keys(this.restrictedDates[year][month]).length === 0) {
-                delete this.restrictedDates[year][month];
-            }
-
-            return true;
-        },
-        isDateRestricted(date, return_bool = true) {
-            const [year, month, day] = this.convertDateForRestriction(date);
-            if (
-                this.restrictedDates[year] === undefined
-                || this.restrictedDates[year][month] === undefined
-                || this.restrictedDates[year][month][day] === undefined
-            ) {
-                return return_bool ? false : {
-                    reason: null,
-                    lock: false,
-                };
-            }
-
-            return return_bool ? true : this.restrictedDates[year][month][day];
-        },
-        convertDateForRestriction(date) {
-            return [date.getFullYear(), date.getMonth() + 1, date.getDate()];
-        }
-    });
     STUDIP.UI.Datepicker = {
         selector: '.has-date-picker,[data-date-picker]',
         // Initialize all datepickers that not yet been initialized (e.g. in dialogs)
diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss
index c647ee8541f..888cf56b9ce 100644
--- a/resources/assets/stylesheets/scss/forms.scss
+++ b/resources/assets/stylesheets/scss/forms.scss
@@ -621,6 +621,28 @@ form.inline {
     }
 }
 
+.studip-dialog {
+    form[data-vue-app] {
+        display: flex;
+        flex-direction: column;
+        min-height: 100%;
+
+        fieldset {
+            flex: 0;
+        }
+
+        footer[data-dialog-button] {
+            background: var(--white);
+            border-top-color: var(--base-color-20);
+            bottom: -0.5em;
+            margin-top: auto;
+            padding: 1.3em 0;
+            position: sticky;
+            text-align: center;
+        }
+    }
+}
+
 @media (min-width: 800px) {
     form.default .form-columns {
         display: flex;
diff --git a/resources/vue/components/ConsultationCreator.vue b/resources/vue/components/ConsultationCreator.vue
new file mode 100644
index 00000000000..375e98a9158
--- /dev/null
+++ b/resources/vue/components/ConsultationCreator.vue
@@ -0,0 +1,496 @@
+<template>
+    <form :action="storeUrl" method="post" class="default" :data-dialog="asDialog ? '' : null" @submit="validateInputs">
+        <input type="hidden" :name="csrf.name" :value="csrf.value">
+        <input v-for="id in responsibleGroups" type="hidden" name="responsibilities[statusgroup][]" :value="id" :key="`group-${id}`">
+        <input v-for="id in responsibleInstitutes" type="hidden" name="responsibilities[institute][]" :value="id" :key="`institute-${id}`">
+        <input v-for="id in responsibleUsers" type="hidden" name="responsibilities[user][]" :value="id" :key="`user-${id}`">
+
+        <StudipMessageBox type="info" v-if="errors.length > 0">
+            {{ $gettext('Folgende Angaben müssen korrigiert werden, um das Formular abschicken zu können:') }}
+
+            <template #details>
+                <ul>
+                    <li v-for="(error, index) in errors" :key="`error-${index}`">
+                        {{ error }}
+                    </li>
+                </ul>
+            </template>
+        </StudipMessageBox>
+
+        <fieldset>
+            <legend>{{ $gettext('Ort und Zeit') }}</legend>
+
+            <label>
+                <span class="required">{{ $gettext('Ort') }}</span>
+
+                <input required type="text" name="room"
+                       v-model="room"
+                       :placeholder="$gettext('Ort')">
+            </label>
+
+            <label :class="{'col-3': !isSingleDay}">
+                <span class="required">{{ $gettext('Intervall') }}</span>
+                <select required name="interval" v-model="interval">
+                    <option v-for="(label, value) in intervals" :key="value" :value="value">
+                        {{ label }}
+                    </option>
+                </select>
+            </label>
+
+            <label class="col-3" v-if="!isSingleDay">
+                <span class="required">{{ $gettext('Am Wochentag') }}</span>
+
+                <select required name="day-of-week" v-model="dayOfWeek">
+                    <option v-for="(label, value) in daysOfTheWeek" :value="value" :key="value">
+                        {{ label }}
+                    </option>
+                </select>
+            </label>
+
+            <label :class="{'col-3': !isSingleDay}">
+                <span class="required">{{ isSingleDay ? $gettext('Datum') : $gettext('Beginn') }}</span>
+
+                <Datepicker v-model="startDate"
+                            name="start-date"
+                            :disable-holidays="true"
+                            :placeholder="$gettext('tt.mm.jjjj')"
+                            mindate="today"
+                            :emit-date="true"
+                ></Datepicker>
+            </label>
+
+            <label class="col-3" v-if="!isSingleDay">
+                <span class="required">{{ $gettext('Ende') }}</span>
+
+                <Datepicker v-model="endDate"
+                            name="end-date"
+                            :disable-holidays="true"
+                            :placeholder="$gettext('tt.mm.jjjj')"
+                            :mindate="startDate"
+                            :emit-date="true"
+                ></Datepicker>
+            </label>
+
+            <label for="start-time" class="col-3">
+                <span class="required">{{ $gettext('Von') }}</span>
+
+                <Timepicker name="start-time"
+                            v-model="startTime"
+                            :maxtime="endTime"
+                ></Timepicker>
+            </label>
+
+            <label for="ende_hour" class="col-3">
+                <span class="required">{{ $gettext('Bis') }}</span>
+
+                <Timepicker name="end-time"
+                            v-model="endTime"
+                            :mintime="startTime"
+                ></Timepicker>
+            </label>
+
+            <label class="col-3">
+                <span class="required">{{ $gettext('Dauer eines Termins in Minuten') }}</span>
+                <input required type="number" name="duration" min="1"
+                       v-model="duration">
+            </label>
+
+            <label class="col-3">
+                {{ $gettext('Maximale Teilnehmerzahl') }}
+                <StudipTooltipIcon :text="$gettext('Falls Sie mehrere Personen zulassen wollen (wie z.B. zu einer Klausureinsicht), so geben Sie hier die maximale Anzahl an Personen an, die sich anmelden dürfen.')"></StudipTooltipIcon>
+                <input required type="text" name="size" id="size" min="1" max="50"
+                       v-model="size">
+            </label>
+
+            <label>
+                <input type="checkbox" name="pause" value="1"
+                       v-model="pause">
+                {{ $gettext('Pausen zwischen den Terminen einfügen?') }}
+            </label>
+
+            <label class="col-3" v-if="pause">
+                {{ $gettext('Eine Pause nach wie vielen Minuten einfügen?') }}
+                <input type="number" name="pause_time" min="1"
+                       v-model="pauseTime">
+            </label>
+
+            <label class="col-3" v-if="pause">
+                {{ $gettext('Dauer der Pause in Minuten') }}
+                <input type="number" name="pause_duration" min="1"
+                       v-model="pauseDuration">
+            </label>
+
+            <label>
+                <input type="checkbox" name="lock" value="1"
+                       v-model="lock">
+                {{ $gettext('Termine für Buchungen sperren?') }}
+            </label>
+
+            <label v-if="lock">
+                {{ $gettext('Wieviele Stunden vor Beginn des Blocks sollen die Termine für Buchungen gesperrt werden?') }}
+                <input type="number" name="lock_time" min="1"
+                       v-model="lockTime">
+            </label>
+
+            <label>
+                <input type="checkbox" name="consecutive" value="1"
+                       v-model="consecutive">
+                {{ $gettext('Termine innerhalb der Blöcke nur fortlaufend vergeben') }}
+            </label>
+
+            <slot name="extension-point-1"></slot>
+        </fieldset>
+
+        <fieldset v-if="withResponsible">
+            <legend>{{ $gettext('Durchführende Personen, Gruppen oder Einrichtungen') }}</legend>
+
+            <template v-if="isInstitute">
+                <p>
+                    {{ $gettext('Bei Einrichtungen muss mindestens eine durchführende Person, Gruppe oder Einrichtung zugewiesen werden.') }}
+                </p>
+                <p>
+                    {{ $gettext('Bitte beachten Sie, dass bei Zuweisungen von Statusgruppen alle Personen der Gruppe mit dem Status '
+                        + '"tutor" und "dozent" als durchführende Personen zugewiesen werden und über alle Buchungen '
+                        + 'informiert werden.') }}
+                    {{ $gettext('Gleiches gilt für eine zugewiesene Einrichtung. Bitte achten Sie darauf, dass Sie Ihre hier '
+                        + ' getroffene Auswahl in Absprache tätigen.') }}
+                </p>
+            </template>
+
+            <label v-if="withResponsible.users">
+                {{ $gettext('Durchführende Personen') }}
+                <StudipSelect v-model="responsibleUsers"
+                              :options="withResponsible.users"
+                              :reduce="option => option.id"
+                              multiple
+                              :clearable="true"
+                >
+                    <template #open-indicator>
+                        <span><studip-icon shape="arr_1down" :size="10" /></span>
+                    </template>
+                </StudipSelect>
+            </label>
+
+            <label v-if="withResponsible.groups">
+                {{ $gettext('Durchführende Gruppen') }}
+                <StudipSelect v-model="responsibleGroups"
+                              :options="withResponsible.groups"
+                              :reduce="option => option.id"
+                              multiple
+                              :clearable="true"
+                >
+                    <template #open-indicator>
+                        <span><studip-icon shape="arr_1down" :size="10" /></span>
+                    </template>
+                </StudipSelect>
+            </label>
+
+            <label v-if="withResponsible.institutes">
+                {{ $gettext('Durchführende Einrichtungen') }}
+                <StudipSelect v-model="responsibleInstitutes"
+                              :options="withResponsible.institutes"
+                              :reduce="option => option.id"
+                              multiple
+                              :clearable="true"
+                >
+                    <template #open-indicator>
+                        <span><studip-icon shape="arr_1down" :size="10" /></span>
+                    </template>
+                </StudipSelect>
+            </label>
+        </fieldset>
+
+        <fieldset>
+            <legend>{{ $gettext('Weitere Einstellungen') }}</legend>
+
+            <label>
+                {{ $gettext('Information zu den Terminen in diesem Block') }}
+                <textarea name="note" v-model="note"></textarea>
+            </label>
+
+            <label>
+                <input type="checkbox" name="calender-events" value="1"
+                       v-model="calendarEvents">
+                {{ $gettext('Die freien Termine auch im Kalender markieren') }}
+            </label>
+
+            <label v-if="isCourse">
+                <input type="checkbox" name="mail-to-tutors" value="1"
+                       v-model="mailToTutors">
+                {{ $gettext('Tutor/innen beim Versand von Buchungsbenachrichtigungen berücksichtigen?') }}
+            </label>
+
+            <label>
+                <input type="checkbox" name="show-participants" value="1"
+                       v-model="showParticipants">
+                {{ $gettext('Namen der buchenden Personen sind öffentlich sichtbar') }}
+            </label>
+
+            <label>{{ $gettext('Grund der Buchung abfragen') }}</label>
+            <div class="hgroup">
+                <label>
+                    <input type="radio" name="require-reason" value="yes"
+                           v-model="requireReason">
+                    {{ $gettext('Ja, zwingend erforderlich') }}
+                </label>
+
+                <label>
+                    <input type="radio" name="require-reason" value="optional"
+                           v-model="requireReason">
+                    {{ $gettext('Ja, optional') }}
+                </label>
+
+                <label>
+                    <input type="radio" name="require-reason" value="no"
+                           v-model="requireReason">
+                    {{ $gettext('Nein') }}
+                </label>
+            </div>
+
+            <label>
+                {{ $gettext('Bestätigung für folgenden Text einholen') }}
+                ({{ $gettext('optional') }})
+                <StudipTooltipIcon :text="$gettext('Wird hier ein Text eingegeben, so müssen Buchende bestätigen, dass sie diesen Text gelesen haben.')"></StudipTooltipIcon>
+                <textarea name="confirmation-text" v-model="confirmationText"></textarea>
+            </label>
+
+            <slot name="extension-point-2"></slot>
+        </fieldset>
+
+        <fieldset v-if="needsConfirmation">
+            <legend>{{ $gettext('Bestätigung der Erstellung vieler Termine') }}</legend>
+
+            <p>
+                {{ $gettext('Sie erstellen eine sehr große Anzahl an Terminen.') }}
+                {{ $gettext('Bitte bestätigen Sie diese Aktion.') }}
+            </p>
+
+            <label>
+                <input type="checkbox" v-model="confirmed">
+                {{ $gettextInterpolate(
+                    $gettext('Ja, ich möchte wirklich %{ n } Termine erstellen.'),
+                    { n: slotCount }
+                ) }}
+            </label>
+        </fieldset>
+
+        <footer data-dialog-button>
+            <button class="accept button" :disabled="!confirmed">
+                {{ $gettext('Termin speichern') }}
+            </button>
+            <a :href="cancelUrl" class="cancel button" @click="evt => closeCreator(evt)">
+                {{ $gettext('Abbrechen') }}
+            </a>
+        </footer>
+    </form>
+</template>
+<script>
+import StudipTooltipIcon from './StudipTooltipIcon.vue';
+import Datepicker from './Datepicker.vue';
+
+import moment from 'moment';
+import StudipSelect from './StudipSelect.vue';
+import Timepicker from './Timepicker.vue';
+
+export default {
+    name: 'ConsultationCreator',
+    components: {Datepicker, StudipSelect, StudipTooltipIcon, Timepicker},
+    props: {
+        asDialog: {
+            type: Boolean,
+            default: false,
+        },
+        cancelUrl: {
+            type: String,
+            required: true
+        },
+        defaultRoom: String,
+        rangeType: {
+            type: String,
+            required: true,
+        },
+        slotCountThreshold: {
+            type: Number,
+            required: true,
+        },
+        storeUrl: {
+            type: String,
+            required: true
+        },
+        withResponsible: {
+            type: [Boolean, Object],
+            default: false,
+        },
+    },
+    data() {
+        return {
+            calendarEvents: false,
+            confirmationText: '',
+            confirmed: false,
+            consecutive: false,
+            dayOfWeek: (new Date()).getDay(),
+            duration: 15,
+            endDate: moment().add(4, 'weeks').toDate(),
+            endTime: '09:00',
+            errors: [],
+            interval: 1,
+            lock: false,
+            lockTime: 24,
+            mailToTutors: true,
+            note: '',
+            pause: false,
+            pauseDuration: 15,
+            pauseTime: 45,
+            requireReason: 'optional',
+            responsibleGroups: [],
+            responsibleInstitutes: [],
+            responsibleUsers: [],
+            room: this.defaultRoom,
+            showParticipants: false,
+            size: 1,
+
+            slotCount: null,
+            startDate: moment().add(1, 'weeks').toDate(),
+            startTime: '08:00',
+        }
+    },
+    computed: {
+        csrf() {
+            return STUDIP.CSRF_TOKEN;
+        },
+        daysOfTheWeek() {
+            return {
+                1: this.$gettext('Montag'),
+                2: this.$gettext('Dienstag'),
+                3: this.$gettext('Mittwoch'),
+                4: this.$gettext('Donnerstag'),
+                5: this.$gettext('Freitag'),
+                6: this.$gettext('Samstag'),
+                0: this.$gettext('Sonntag'),
+            };
+        },
+        intervals() {
+            return {
+                0: this.$gettext('einmalig (ohne Wiederholung)'),
+                1: this.$gettext('wöchentlich'),
+                2: this.$gettext('zweiwöchentlich'),
+                3: this.$gettext('dreiwöchentlich'),
+                4: this.$gettext('monatlich'),
+            };
+        },
+        isCourse() {
+            return this.rangeType === 'Course';
+        },
+        isInstitute() {
+            return this.rangeType === 'Institute';
+        },
+        isSingleDay() {
+            return this.interval === '0';
+        },
+        needsConfirmation() {
+            return this.slotCount > this.slotCountThreshold;
+        },
+        recalculationProperty() {
+            return [
+                this.startDate,
+                this.startTime,
+                this.endDate,
+                this.endTime,
+                this.dayOfWeek,
+                this.interval,
+                this.duration,
+                this.pause,
+                this.pauseTime,
+                this.pauseDuration,
+            ].join();
+        },
+    },
+    methods: {
+        closeCreator(event) {
+            if (this.$el.closest('.studip-dialog')) {
+                STUDIP.Dialog.close();
+                event.preventDefault();
+            }
+        },
+        validateInputs(event) {
+            const errors = [];
+
+            if (this.startTime > this.endTime) {
+                errors.push(this.$gettext('Die Endzeit liegt vor der Startzeit!'));
+            }
+
+            if (this.startDate > this.endDate) {
+                errors.push(this.$gettext('Das Enddatum liegt vor dem Startdatum!'));
+            }
+
+            if (this.pauseTime && this.pauseTime < this.duration) {
+                errors.push(this.$gettext('Die definierte Zeit bis zur Pause ist kleiner als die Dauer eines Termins.'));
+            }
+
+            if (
+                this.isInstitute
+                && this.responsibleGroups.length === 0
+                && this.responsibleInstitutes.length === 0
+                && this.responsibleUsers.length === 0
+            ) {
+                errors.push(this.$gettext('Es muss mindestens eine durchführende Person, Statusgruppe oder Einrichtung ausgewählt werden.'));
+            }
+
+            if (this.needsConfirmation && !this.confirmed) {
+                errors.push(this.$gettext('Sie müssen bestätigen, dass sie eine große Anzahl von Terminen erstellen möchten.'));
+
+            }
+
+            if (errors.length > 0) {
+                this.errors = errors;
+                event.preventDefault();
+            }
+        },
+        combineDateAndTime(date, time) {
+            const [hour, minute] = time.split(':').map(item => parseInt(item, 10));
+            const result = new Date(date);
+            result.setHours(hour);
+            result.setMinutes(minute);
+            result.setSeconds(0);
+            return result;
+        }
+    },
+    watch: {
+        interval(current) {
+            if (current === '0') {
+                this.endDate = new Date(this.startDate);
+            }
+        },
+        recalculationProperty: {
+            handler() {
+                STUDIP.jsonapi.withPromises().GET('consultation-slots/count', {
+                    data: {
+                        start: this.combineDateAndTime(this.startDate, this.startTime).toISOString(),
+                        end: this.combineDateAndTime(this.endDate, this.endTime).toISOString(),
+                        dow: this.dayOfWeek,
+                        interval: this.interval,
+                        duration: this.duration,
+                        pause_time: this.pause ? this.pauseTime : null,
+                        pause_duration: this.pause ? this.pauseDuration : null,
+                    }
+                }).then((count) => {
+                    this.slotCount = count;
+                    this.confirmed = count <= this.slotCountThreshold;
+                });
+            },
+            immediate: true
+        },
+        startDate(current) {
+            this.dayOfWeek = current.getDay();
+        },
+    },
+    beforeCreate() {
+        STUDIP.Vue.emit('ConsultationCreatorWillCreate', this);
+    }
+}
+</script>
+<style scoped>
+form.default label input[type="time"] {
+    max-width: 48em;
+}
+</style>
diff --git a/resources/vue/components/Datepicker.vue b/resources/vue/components/Datepicker.vue
index 3db44ceb971..5c2c0f75cb7 100644
--- a/resources/vue/components/Datepicker.vue
+++ b/resources/vue/components/Datepicker.vue
@@ -1,75 +1,143 @@
 <template>
     <span>
-        <input type="hidden" :name="name" :value="value">
+        <input type="hidden" :name="name" :value="returnValue">
         <input type="text"
                ref="visibleInput"
                class="visible_input"
-               @change="setUnixTimestamp"
                v-bind="$attrs"
-               v-on="$listeners">
+               v-on="$listeners"
+               :placeholder="placeholder">
     </span>
 </template>
 
 <script>
+import RestrictedDatesHelper from '../../assets/javascripts/lib/RestrictedDatesHelper';
+
 export default {
-    name: "datepicker",
+    name: 'Datepicker',
     inheritAttrs: false,
     props: {
         name: {
             type: String,
             required: false
         },
-        value: {
-            required: false
+        value: [Date, String, Number],
+        mindate: [Date, Number, String],
+        maxdate: [Date, Number, String],
+        placeholder: String,
+        disableHolidays: {
+            type: Boolean,
+            default: false,
         },
-        mindate: {
-            required: false
+        emitDate: {
+            type: Boolean,
+            default: false,
         },
-        maxdate: {
-            required: false
+        returnAs: {
+            type: String,
+            default: 'localized',
+            validator(value) {
+                return ['localized', 'unix', 'iso'].includes(value);
+            }
+        }
+    },
+    computed: {
+        input() {
+            return $(this.$refs.visibleInput);
+        },
+        parameters() {
+            let params = {
+                onSelect: () => {
+                    this.setUnixTimestamp();
+                },
+                maxDate: this.convertInputToNativeDate(this.maxdate),
+                minDate: this.convertInputToNativeDate(this.mindate),
+            };
+            if (this.disableHolidays) {
+                params.beforeShowDay = (date) => {
+                    RestrictedDatesHelper.loadRestrictedDatesByYear(date.getFullYear()).then(
+                        () => this.input.datepicker('refresh'),
+                        () => null
+                    );
+
+                    const {reason, lock} = RestrictedDatesHelper.isDateRestricted(date);
+                    return [!lock, lock ? 'ui-datepicker-is-locked' : null, reason];
+                };
+            }
+
+            return params;
+        },
+        returnValue() {
+            if (this.returnAs === 'unix') {
+                return this.convertInputToUnixTimestamp(this.value);
+            }
+
+            if (this.returnAs === 'iso') {
+                return this.convertInputToNativeDate(this.value).toISOString();
+            }
+
+            return this.convertInputToNativeDate(this.value).toLocaleDateString();
         }
     },
     methods: {
+        convertInputToNativeDate(input) {
+            if (input instanceof Date) {
+                return input;
+            }
+
+            if (input === 'today') {
+                return new Date();
+            }
+
+            return input ? new Date(input * 1000) : null;
+        },
+        convertInputToUnixTimestamp(input) {
+            if (input instanceof Date) {
+                return Math.floor(input.getTime() / 1000);
+            }
+
+            if (!isNaN(parseInt(input, 10))) {
+                return parseInt(input, 10);
+            }
+
+            return input;
+        },
         setUnixTimestamp () {
-            let formatted_date = this.$refs.visibleInput.value;
-            let date = formatted_date.match(/(\d+)/g);
-            date = new Date(`${date[2]}-${date[1]}-${date[0]} ${date[3]}:${date[4]}`);
-            this.$emit('input', Math.floor(date / 1000));
+            let date = this.input.datepicker('getDate');
+            this.$emit('input', this.emitDate ? date : Math.floor(date.getTime() / 1000));
         }
     },
     mounted () {
-        let value = !isNaN(parseInt(this.value, 10)) ? parseInt(this.value, 10) : this.value;
+        let value = this.convertInputToUnixTimestamp(this.value);
+
         if (Number.isInteger(value)) {
             let date = new Date(value * 1000);
-            let formatted_date =
-                (date.getDate() < 10 ? "0" : "") + date.getDate()
-                + "."
-                + (date.getMonth() < 9 ? "0" : "") + (date.getMonth() + 1)
-                + "."
-                + date.getFullYear();
-            this.$refs.visibleInput.value = formatted_date;
+            this.input.val(date.toLocaleDateString());
         } else {
-            this.$refs.visibleInput.value = value;
-        }
-        let params = {
-            onSelect: () => {
-                this.setUnixTimestamp();
-            }
-        };
-        if (this.mindate) {
-            params.minDate = new Date(this.mindate * 1000)
-        }
-        if (this.maxdate) {
-            params.maxDate = new Date(this.maxdate * 1000)
+            this.input.val(value);
         }
-        $(this.$refs.visibleInput).datetimepicker(params);
+        this.input.datepicker(this.parameters);
     },
     watch: {
-        mindate (new_data, old_data) {
-            $(this.$refs.visibleInput).datetimepicker('option', 'minDate', new Date(new_data * 1000));
+        maxdate(current) {
+            this.input.datepicker(
+                'option',
+                'maxDate',
+                this.convertInputToNativeDate(current)
+            );
         },
-        maxdate (new_data, old_data) {
-            $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', new Date(new_data * 1000));
+        mindate(current) {
+            this.input.datepicker(
+                'option',
+                'minDate',
+                this.convertInputToNativeDate(current)
+            );
+        },
+        value(current, previous) {
+            if (current.toISOString() !== previous.toISOString()) {
+                this.input.datepicker('setDate', current);
+                this.input.datepicker('refresh');
+            }
         }
     }
 }
diff --git a/resources/vue/components/StudipTooltipIcon.vue b/resources/vue/components/StudipTooltipIcon.vue
index 39856bba371..30d20334522 100644
--- a/resources/vue/components/StudipTooltipIcon.vue
+++ b/resources/vue/components/StudipTooltipIcon.vue
@@ -37,6 +37,9 @@
 </script>
 
 <style lang="scss" scoped>
+.tooltip img {
+    vertical-align: text-bottom;
+}
 .tooltip.tooltip-icon::before {
     display: none;
 }
diff --git a/resources/vue/components/Timepicker.vue b/resources/vue/components/Timepicker.vue
new file mode 100644
index 00000000000..e0b0febfe5c
--- /dev/null
+++ b/resources/vue/components/Timepicker.vue
@@ -0,0 +1,37 @@
+<template>
+    <input type="time"
+           ref="visibleInput"
+           class="hasTimepicker"
+           v-model="timeValue"
+           :placeholder="placeholder"
+           :min="mintime"
+           :max="maxtime"
+           :name="name">
+</template>
+
+<script>
+export default {
+    name: 'Timepicker',
+    inheritAttrs: false,
+    props: {
+        name: {
+            type: String,
+            required: false
+        },
+        value: String,
+        mintime: String,
+        maxtime: String,
+        placeholder: String,
+    },
+    computed: {
+        timeValue: {
+            get() {
+                return this.value;
+            },
+            set(value) {
+                this.$emit('input', value);
+            }
+        }
+    }
+}
+</script>
-- 
GitLab