diff options
| author | Moritz Strohm <strohm@data-quest.de> | 2025-10-15 14:45:09 +0000 |
|---|---|---|
| committer | Moritz Strohm <strohm@data-quest.de> | 2025-10-15 14:45:09 +0000 |
| commit | 2fffc8a81a1f3c3665efac516aab93e823e6cd14 (patch) | |
| tree | 012bbaa4c868f701c105fc8cd4ee5a551c8cdf73 | |
| parent | 894dd34eed77192d955c32783bc4d874c0716c2c (diff) | |
TIC 2832, closes #2832
Closes #2832
Merge request studip/studip!4453
| -rw-r--r-- | lib/classes/JsonApi/RouteMap.php | 2 | ||||
| -rw-r--r-- | lib/classes/JsonApi/Routes/Vacations/VacationsShow.php | 80 | ||||
| -rw-r--r-- | lib/models/SemesterHoliday.php | 6 | ||||
| -rw-r--r-- | resources/assets/javascripts/lib/fullcalendar.js | 147 | ||||
| -rw-r--r-- | resources/assets/stylesheets/fullcalendar.scss | 21 |
5 files changed, 249 insertions, 7 deletions
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index ca53a4a..40b535d 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\Routes\Consultations\SlotCreationCount; use JsonApi\Routes\Holidays\HolidaysShow; +use JsonApi\Routes\Vacations\VacationsShow; use Slim\Routing\RouteCollectorProxy; /** @@ -154,6 +155,7 @@ class RouteMap \PluginEngine::sendMessage(JsonApiPlugin::class, 'registerUnauthenticatedRoutes', $group); $group->get('/holidays', HolidaysShow::class); + $group->get('/vacations', VacationsShow::class); $group->get('/semesters', Routes\SemestersIndex::class); $group->get('/semesters/{id}', Routes\SemestersShow::class)->setName('get-semester'); diff --git a/lib/classes/JsonApi/Routes/Vacations/VacationsShow.php b/lib/classes/JsonApi/Routes/Vacations/VacationsShow.php new file mode 100644 index 0000000..1e4fa1e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Vacations/VacationsShow.php @@ -0,0 +1,80 @@ +<?php + +namespace JsonApi\Routes\Vacations; + +use JsonApi\NonJsonApiController; +use Psr\Container\ContainerInterface; +use JsonApi\JsonApiIntegration\QueryParserInterface; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Neomerx\JsonApi\Schema\Error; +use Neomerx\JsonApi\Schema\ErrorCollection; +use Neomerx\JsonApi\Exceptions\JsonApiException; + +class VacationsShow extends NonJsonApiController +{ + protected $allowed_filtering_parameters = ['year', 'month']; + + public function __construct( + ContainerInterface $container, + private readonly QueryParserInterface $queryParser + ) { + parent::__construct($container); + } + + public function __invoke(Request $request, Response $response, array $args): Response + { + $errors = new ErrorCollection(); + + $filters = $this->queryParser->getFilteringParameters(); + if (isset($filters['month']) && empty($filters['year'])) { + $errors->add(new Error( + 'invalid-filter-value', + title: 'The month filter cannot be used without the year filter.' + )); + } + + if ($errors->count() > 0) { + throw new JsonApiException($errors, JsonApiException::HTTP_CODE_BAD_REQUEST); + } + + if (empty($filters['year'])) { + $filters['year'] = date('Y'); + } + + $start = new \DateTime(); + $start->setTime(0,0,0); + + // Calculate the time span: + if (!empty($filters['month'])) { + // For one month: + $start->setDate($filters['year'], $filters['month'], 1); + $end = clone $start; + $end = $end->add(new \DateInterval('P1M'))->sub(new \DateInterval('PT1S')); + } else { + // For a whole year: + $start->setDate($filters['year'], 1, 1); + $end = clone $start; + $end = $end->add(new \DateInterval('P1Y'))->sub(new \DateInterval('PT1S')); + } + + $vacation_objects = \SemesterHoliday::findByTimestampRange($start->getTimestamp(), $end->getTimestamp()); + + $vacations = []; + foreach ($vacation_objects as $vacation_object) { + $vacations[$vacation_object->id] = [ + 'id' => $vacation_object->id, + 'name' => $vacation_object->name, + 'semester_id' => $vacation_object->semester_id, + 'description' => $vacation_object->description, + 'start' => $vacation_object->beginn, + 'end' => $vacation_object->ende, + 'mkdate' => $vacation_object->mkdate, + 'chdate' => $vacation_object->chdate + ]; + } + + $response->getBody()->write(json_encode($vacations)); + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/lib/models/SemesterHoliday.php b/lib/models/SemesterHoliday.php index ab058e1..ce56e48 100644 --- a/lib/models/SemesterHoliday.php +++ b/lib/models/SemesterHoliday.php @@ -56,9 +56,9 @@ class SemesterHoliday extends SimpleORMap /** * returns all SemesterHoliday between given timestamps (starting AND ending within given timestamps) - * @param integer $timestamp_start - * @param integer $timestamp_end - * @return array of SemesterHoliday + * @param int $timestamp_start + * @param int $timestamp_end + * @return SemesterHoliday[] */ public static function findByTimestampRange($timestamp_start, $timestamp_end) { diff --git a/resources/assets/javascripts/lib/fullcalendar.js b/resources/assets/javascripts/lib/fullcalendar.js index a6eedbe..5882460 100644 --- a/resources/assets/javascripts/lib/fullcalendar.js +++ b/resources/assets/javascripts/lib/fullcalendar.js @@ -32,6 +32,99 @@ function pad(what, length = 2, char = '0') { class Fullcalendar { + static holidayCache = sessionStorage.getItem('fullcalendar_holidays') ? JSON.parse(sessionStorage.getItem('fullcalendar_holidays')) : {}; + static vacationCache = sessionStorage.getItem('fullcalendar_vacations') ? JSON.parse(sessionStorage.getItem('fullcalendar_vacations')) : {}; + + static loadHolidays(year) { + if (this.holidayCache[year]) { + return Promise.resolve(this.holidayCache[year]); + } + + return STUDIP.jsonapi.withPromises().get('holidays', { + data: { 'filter[year]': year } + }).then(response => { + const events = []; + if (!response) { + return events; + } + + for (const [date, data] of Object.entries(response)) { + const classNames = ['holiday']; + if (data.mandatory) { + classNames.push('official'); + } + + const day = new Date(date); + events.push({ + // Note: Since allDay is set to true, the start and end time is ignored. + // See the documentation: https://fullcalendar.io/docs/v4/event-parsing + start: day, + end: day, + allDay: true, + title: data.holiday, + editable: false, + + classNames, + + // Note: Colours are set via SCSS. + textColor: '', + color: '', + borderColor: '', + + rendering: 'background' + }); + } + + this.holidayCache[year] = events; + + sessionStorage.setItem('fullcalendar_holidays', JSON.stringify(this.holidayCache)); + + return events; + }); + } + + static loadVacations(year) { + if (this.vacationCache[year]) { + return Promise.resolve(this.vacationCache[year]); + } + + return STUDIP.jsonapi.withPromises().get('vacations', { + data: {'filter[year]': year} + }).then(response => { + if (!response) { + return []; + } + + const items = []; + + for (const vacation_data of Object.values(response)) { + const start = new Date(parseInt(vacation_data.start) * 1000); + const end = new Date(parseInt(vacation_data.end) * 1000); + items.push({ + start, + end, + allDay: true, + title: vacation_data.name, + editable: false, + classNames: ['holiday'], + + // Note: Colours are set via SCSS. + textColor: '', + color: '', + borderColor: '', + + rendering: 'background' + }); + } + + this.vacationCache[year] = items; + + sessionStorage.setItem('fullcalendar_vacations', JSON.stringify(this.vacationCache)); + + return items; + }); + } + /** * The initialisation method. It loads the JS files for fullcalendar * in case they are not loaded and sets up a fullcalendar instance @@ -573,9 +666,9 @@ class Fullcalendar } }, eventRender (info) { - var event = info.event; - var eventElement = info.el; - var iconColor = event.textColor == '#000000' ? 'black' : 'white'; + let event = info.event; + let eventElement = info.el; + let iconColor = event.textColor === '#000000' ? 'black' : 'white'; if ($(info.view.context.calendar.el).hasClass('institute-plan')) { $(eventElement).attr('title', event.extendedProps.tooltip); @@ -618,6 +711,34 @@ class Fullcalendar title.prepend(icon_element); } } + + //If a background event with title shall be rendered, we have to check + //if an all-day slot is present or not. + let generate_title = false; + if (event.rendering === 'background' && event.title && event.allDay) { + if (info.view.viewSpec.options.allDaySlot === true) { + //An all-day slot is present in the calendar. + if (info.isStart === true || info.isEnd === true) { + + generate_title = true; + } + } else { + //No all-day slot in the calendar. Display a title + //at the start of the day in the calendar. + if (info.isStart === false || info.isEnd === false) { + generate_title = true; + } + } + } + if (generate_title) { + //Generate a visible title for the background element: + let element = $('<div class="title"></div>'); + $(element).text(event.title); + if (event.textColor) { + element.css('color', event.textColor); + } + $(eventElement).append(element); + } }, eventSourceSuccess: function(content) { if ($(node).hasClass('semester-plan')) { @@ -732,6 +853,26 @@ class Fullcalendar config = $.extend({}, config, additional_config); + if (!Array.isArray(config.eventSources)) { + config.eventSources = []; + } + config.eventSources.push({ + events: (fetchInfo, successCallback, failureCallback) => { + const startYear = fetchInfo.start.getFullYear(); + const endYear = fetchInfo.end.getFullYear(); + const requests = []; + for (let year = startYear; year <= endYear; year++) { + requests.push(this.loadHolidays(year)); + requests.push(this.loadVacations(year)); + } + Promise.all(requests).then(results => { + const events = [].concat(...results); + successCallback(events); + return results; + }).catch(failureCallback); + }, + }); + config.defaultView = defaultView; return this.init(node, config); diff --git a/resources/assets/stylesheets/fullcalendar.scss b/resources/assets/stylesheets/fullcalendar.scss index 8edf7b6..70ed244 100644 --- a/resources/assets/stylesheets/fullcalendar.scss +++ b/resources/assets/stylesheets/fullcalendar.scss @@ -2,7 +2,8 @@ @import "scss/buttons"; @import "mixins"; -a.fc-event, td.fc-event { +a.fc-event, +td.fc-event { border-radius: 0; .fc-time { @@ -25,6 +26,24 @@ a.fc-event, td.fc-event { } } +div.fc-bgevent, +td.fc-bgevent { + .title { + color: $text-color; + font-weight: bold; + text-align: center; + } + + &.holiday { + opacity: 1; + background-color: rgba($light-gray-color-60, 0.3); + + &.official { + background-color: rgba($base-gray, 0.3); + } + } +} + .fc { .fc-toolbar.fc-header-toolbar { margin-bottom: 0.5em; |
