aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMoritz Strohm <strohm@data-quest.de>2025-10-15 14:45:09 +0000
committerMoritz Strohm <strohm@data-quest.de>2025-10-15 14:45:09 +0000
commit2fffc8a81a1f3c3665efac516aab93e823e6cd14 (patch)
tree012bbaa4c868f701c105fc8cd4ee5a551c8cdf73
parent894dd34eed77192d955c32783bc4d874c0716c2c (diff)
TIC 2832, closes #2832
Closes #2832 Merge request studip/studip!4453
-rw-r--r--lib/classes/JsonApi/RouteMap.php2
-rw-r--r--lib/classes/JsonApi/Routes/Vacations/VacationsShow.php80
-rw-r--r--lib/models/SemesterHoliday.php6
-rw-r--r--resources/assets/javascripts/lib/fullcalendar.js147
-rw-r--r--resources/assets/stylesheets/fullcalendar.scss21
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;