diff --git a/app/views/consultation/admin/create.php b/app/views/consultation/admin/create.php index aa4ee30636ae46f58518e8cc559bfe06929192ee..ff0b4831d4c982fff7b10563b3bc30aa9700d801 100644 --- a/app/views/consultation/admin/create.php +++ b/app/views/consultation/admin/create.php @@ -61,7 +61,7 @@ $intervals = [ <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"}'> + data-date-picker='{">=":"today","disable_holidays": true}'> </label> <label class="col-3"> @@ -70,7 +70,7 @@ $intervals = [ <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"}'> + data-date-picker='{">=":"#start-date","disable_holidays": true}'> </label> <label class="col-3"> diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 0709ef3a75d82e7c760d22c5b2b59e1b6c9bac92..bffa13a59bdd8e53c1b627e0d457bdffb3bd6e2d 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -7,6 +7,7 @@ use JsonApi\Middlewares\Authentication; use JsonApi\Middlewares\DangerousRouteHandler; use JsonApi\Middlewares\JsonApi as JsonApiMiddleware; use JsonApi\Middlewares\StudipMockNavigation; +use JsonApi\Routes\Holidays\HolidaysShow; use Slim\Routing\RouteCollectorProxy; /** @@ -143,6 +144,8 @@ class RouteMap { \PluginEngine::sendMessage(JsonApiPlugin::class, 'registerUnauthenticatedRoutes', $group); + $group->get('/holidays', HolidaysShow::class); + $group->get('/semesters', Routes\SemestersIndex::class); $group->get('/semesters/{id}', Routes\SemestersShow::class)->setName('get-semester'); @@ -561,4 +564,3 @@ class RouteMap $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); } } - diff --git a/lib/classes/JsonApi/Routes/Holidays/HolidaysShow.php b/lib/classes/JsonApi/Routes/Holidays/HolidaysShow.php new file mode 100644 index 0000000000000000000000000000000000000000..ef3fa61a14b6e7ff334bd9ac10a99fda58f0c04a --- /dev/null +++ b/lib/classes/JsonApi/Routes/Holidays/HolidaysShow.php @@ -0,0 +1,172 @@ +<?php + +namespace JsonApi\Routes\Holidays; + +use GuzzleHttp\Psr7; +use JsonApi\JsonApiIntegration\QueryParserInterface; +use JsonApi\NonJsonApiController; +use Neomerx\JsonApi\Exceptions\JsonApiException; +use Neomerx\JsonApi\Schema\Error; +use Neomerx\JsonApi\Schema\ErrorCollection; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; + +/** + * List all holidays for a specific time period. + * + * Filter are allowed for year, month and days. You must specify a year in order + * to filter by a month and you must specify a month in order to filter by a + * day. + * + * If no filter is set, a filter the current year is assumed. + */ +final class HolidaysShow extends NonJsonApiController +{ + private $query_parser; + + protected $allowedFilteringParameters = ['year', 'month', 'day']; + + public function __construct( + ContainerInterface $container, + QueryParserInterface $queryParser + ) { + parent::__construct($container); + + $this->query_parser = $queryParser; + } + + public function __invoke(Request $request, Response $response, $args): Response + { + [$current, $end] = $this->getTimespanByFilters(); + + $holidays = []; + while ($current < $end) { + $holiday = holiday($current); + if ($holiday) { + $holidays[date('Y-m-d', $current)] = [ + 'holiday' => $holiday['name'], + 'mandatory' => $holiday['col'] === 3, + ]; + } + + $current = strtotime('+1 day', $current); + } + + return $response + ->withHeader('Content-Type', 'application/json') + ->withBody(Psr7\Utils::streamFor(json_encode($holidays))); + } + + private function getTimespanByFilters(): array + { + $filters = $this->getFilters(); + + $begin = mktime( + 0, + 0, + 0, + $filters['month'] ?? 1, + $filters['day'] ?? 1, + $filters['year'] + ); + + $end = mktime( + 23, + 59, + 59, + $filters['month'] ?? 12, + $filters['day'] ?? $this->getLastDayOfMonth($filters['year'], $filters['month'] ?? 12), + $filters['year'] + ); + + return [$begin, $end]; + } + + private function getLastDayOfMonth(int $year, int $month): int + { + $first_of_month = mktime(0, 0, 0, $month, 1, $year); + $last_day_of_month = strtotime('last day of this month', $first_of_month); + return (int) date('d', $last_day_of_month); + } + + /** + * @todo imporove error handling + * @return array + */ + private function getFilters(): array + { + $errors = new ErrorCollection(); + + // Get filters + $filters = $this->query_parser->getFilteringParameters(); + + // Validate allowed filters + foreach ($filters as $key => $value) { + if (!in_array($key, $this->allowedFilteringParameters)) { + $errors->add(new Error( + 'invalid-filter-field', + null, null, + null, null, + 'Filter should contain only allowed values.', + "Cannot filter by {$key}", + ['filter' => $key] + )); + } + } + + // Validate month + if (isset($filters['month']) && !isset($filters['year'])) { + $errors->add(new Error( + 'missing-year-filter', + null, null, + null, null, + 'You must not define a month filter without a year filter' + )); + } elseif ( + isset($filters['month']) + && ($filters['month'] < 1 || $filters['month'] > 12) + ) { + $errors->add(new Error( + 'invalid-filter-value', + null, null, + null, null, + 'Filter should contain only allowed values.', + "Invalid value {$filters['month']} for month filter", + ['filter' => 'month', 'value' => $filters['month']] + )); + } + + // Validate day + if (isset($filters['day']) && !isset($filters['month'])) { + $errors->add(new Error( + 'missing-month-filter', + null, null, + null, null, + 'You must not define a day filter without a month filter' + )); + } elseif ( + isset($filters['day']) + && ( + $filters['day'] < 1 + || $filters['day'] > $this->getLastDayOfMonth((int) $filters['year'] ?? date('Y'), $filters['month']) + ) + ) { + $errors->add(new Error( + 'invalid-filter-value', + null, null, + null, null, + 'Filter should contain only allowed values.', + "Invalid value {$filters['day']} for day filter", + ['filter' => 'day', 'value' => $filters['day']] + )); + } + + if ($errors->count() > 0) { + throw new JsonApiException($errors, JsonApiException::HTTP_CODE_BAD_REQUEST); + } + + // Apply defaults + return array_merge(['year' => date('Y')], $filters); + } +} diff --git a/resources/assets/javascripts/studip-ui.js b/resources/assets/javascripts/studip-ui.js index 41897b204b94e5b3d17c2e88b15dbde60fae38f6..63766c08d1b3477f4a88aeb6decf051444aac82b 100644 --- a/resources/assets/javascripts/studip-ui.js +++ b/resources/assets/javascripts/studip-ui.js @@ -27,6 +27,37 @@ import eventBus from "./lib/event-bus.ts"; return element?.classList?.contains('ck-body-wrapper'); } + function disableHolidaysBeforeShow(date) { + const year = date.getFullYear(); + + if (STUDIP.UI.restrictedDates[year] === undefined) { + STUDIP.UI.restrictedDates[year] = {}; + + STUDIP.jsonapi.GET('holidays', {data: { + 'filter[year]': year + }}).done(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); + return [!lock, lock ? 'ui-datepicker-is-locked' : null, reason]; + } + /** * Setup and refine date picker, add automated handling for .has-date-picker * and [data-date-picker]. @@ -53,27 +84,84 @@ import eventBus from "./lib/event-bus.ts"; } // Setup Stud.IP's own datepicker extensions - STUDIP.UI = STUDIP.UI || {}; + 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) - init: function () { + init() { $(this.selector).filter(function () { return $(this).data('date-picker-init') === undefined; }).each(function () { - $(this).data('date-picker-init', true).datepicker(); + const dataOptions = $(this).data().datePicker; + + const options = {}; + if ( + dataOptions['disable_holidays'] !== undefined + && dataOptions['disable_holidays'] === true + ) { + options.beforeShowDay = disableHolidaysBeforeShow; + } + $(this).data('date-picker-init', true).datepicker(options); }); }, // Apply registered handlers. Take care: This happens upon before a // picker is shown as well as after a date has been selected. - refresh: function () { + refresh() { $(this.selector).each(function () { - var element = this, - options = $(element).data().datePicker; + const options = $(this).data().datePicker; if (options) { - $.each(options, function (key, value) { + $.each(options, (key, value) => { if (STUDIP.UI.Datepicker.dataHandlers[key] !== undefined) { - STUDIP.UI.Datepicker.dataHandlers[key].call(element, value); + STUDIP.UI.Datepicker.dataHandlers[key].call(this, value); } }); } @@ -190,23 +278,31 @@ import eventBus from "./lib/event-bus.ts"; STUDIP.UI.DateTimepicker = { selector: '.has-datetime-picker,[data-datetime-picker]', // Initialize all datetimepickers that not yet been initialized (e.g. in dialogs) - init: function () { + init() { $(this.selector).filter(function () { return $(this).data('datetime-picker-init') === undefined; }).each(function () { - $(this).data('datetime-picker-init', true).datetimepicker(); + const dataOptions = $(this).data().datePicker; + + const options = {}; + if ( + dataOptions['disable_holidays'] !== undefined + && dataOptions['disable_holidays'] === true + ) { + options.beforeShowDay = disableHolidaysBeforeShow; + } + $(this).data('date-picker-init', true).datepicker(options); }); }, // Apply registered handlers. Take care: This happens upon before a // picker is shown as well as after a date has been selected. - refresh: function () { + refresh() { $(this.selector).each(function () { - var element = this, - options = $(element).data().datetimePicker; + const options = $(this).data().datetimePicker; if (options) { - $.each(options, function (key, value) { + $.each(options, (key, value) => { if (STUDIP.UI.DateTimepicker.dataHandlers[key] !== undefined) { - STUDIP.UI.DateTimepicker.dataHandlers[key].call(element, value); + STUDIP.UI.DateTimepicker.dataHandlers[key].call(this, value); } }); } @@ -317,7 +413,7 @@ import eventBus from "./lib/event-bus.ts"; STUDIP.UI.Timepicker = { selector: '.has-time-picker,[data-time-picker]', // Initialize all datetimepickers that not yet been initialized (e.g. in dialogs) - init: function () { + init() { $(this.selector).filter(function () { return $(this).data('time-picker-init') === undefined; }).each(function () { @@ -326,7 +422,7 @@ import eventBus from "./lib/event-bus.ts"; }, // Apply registered handlers. Take care: This happens upon before a // picker is shown as well as after a date has been selected. - refresh: function () { + refresh() { $(this.selector).each(function () { var element = this, options = $(element).data().timePicker; @@ -395,7 +491,6 @@ import eventBus from "./lib/event-bus.ts"; parsed.minute ); - console.log('max time:', this_time, max_time); if (this_time && this_time > max_time) { $(this).timepicker(STUDIP.UI.Timepicker.parseTime(max_time)); } @@ -446,7 +541,6 @@ import eventBus from "./lib/event-bus.ts"; parsed.minute ); - console.log('min time:', this_time, min_time); if (this_time && this_time < min_time) { $(this).timepicker(STUDIP.UI.Timepicker.parseTime(min_time)); } diff --git a/resources/assets/stylesheets/studip-jquery-ui.less b/resources/assets/stylesheets/studip-jquery-ui.less index c9fb6defef61ab9df35995996b3301165e4711bb..2303779361dbbd6d8c0b0f1684e6bda6ecd7b624 100644 --- a/resources/assets/stylesheets/studip-jquery-ui.less +++ b/resources/assets/stylesheets/studip-jquery-ui.less @@ -176,3 +176,10 @@ .ui-menu .ui-menu-item { list-style: none; } + +.ui-datepicker-calendar { + // This will reenable the tooltip + .ui-datepicker-unselectable.ui-datepicker-is-locked { + pointer-events: all; + } +}