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