Merge branch 'merge_C4_25999_61235_61730_calendar' into merge_C4_25999_61235_61730

This commit is contained in:
chfhtw
2025-07-24 10:46:23 +02:00
52 changed files with 3951 additions and 1288 deletions
+6
View File
@@ -0,0 +1,6 @@
<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
// Define configuration parameters
$config['timezone'] = 'Europe/Vienna';
+4
View File
@@ -15,6 +15,9 @@ class LvPlan extends Auth_Controller
parent::__construct([
'index' => ['basis/cis:r']
]);
// Load Config
$this->load->config('calendar');
}
// -----------------------------------------------------------------------------------------------------------------
@@ -28,6 +31,7 @@ class LvPlan extends Auth_Controller
$viewData = array(
'uid'=>getAuthUID(),
'timezone' => $this->config->item('timezone')
);
$this->load->view('CisRouterView/CisRouterView.php', ['viewData' => $viewData, 'route' => 'LvPlan']);
+8 -4
View File
@@ -17,6 +17,9 @@ class Cis4 extends Auth_Controller
'index' => 'basis/cis:r'
)
);
// Load Config
$this->load->config('calendar');
}
// -----------------------------------------------------------------------------------------------------------------
@@ -27,15 +30,16 @@ class Cis4 extends Auth_Controller
*/
public function index()
{
$this->load->model('person/Person_model','PersonModel');
$this->load->model('person/Person_model', 'PersonModel');
$personData = getData($this->PersonModel->getByUid(getAuthUID()))[0];
$viewData = array(
'uid' => getAuthUID(),
'name' => $personData->vorname,
'person_id' => $personData->person_id
'person_id' => $personData->person_id,
'timezone' => $this->config->item('timezone')
);
$this->load->view('CisRouterView/CisRouterView.php',['viewData' => $viewData, 'route' => 'FhcDashboard']);
$this->load->view('CisRouterView/CisRouterView.php', ['viewData' => $viewData, 'route' => 'FhcDashboard']);
}
}
}
+7 -2
View File
@@ -87,9 +87,14 @@ class Cms extends Auth_Controller
$this->load->view('CisRouterView/CisRouterView.php', ['viewData'=>$viewData, 'route' => 'News']);
}
public function getRoomInformation($ort_kurzbz){
public function getRoomInformation($ort_kurzbz)
{
// Load Config
$this->load->config('calendar');
$viewData = array(
'ort_kurzbz' => $ort_kurzbz
'ort_kurzbz' => $ort_kurzbz,
'timezone' => $this->config->item('timezone')
);
$this->load->view('CisRouterView/CisRouterView.php', ['viewData' => $viewData, 'route' => 'CmsRoom']);
}
@@ -32,7 +32,7 @@ class LvPlan extends FHCAPI_Controller
parent::__construct([
'getRoomplan' => self::PERM_LOGGED,
'Stunden' => self::PERM_LOGGED,
'Reservierungen' => self::PERM_LOGGED,
'getReservierungen' => self::PERM_LOGGED,
'LvPlanEvents' => self::PERM_LOGGED,
'getLehreinheitStudiensemester' => self::PERM_LOGGED,
'studiensemesterDateInterval' => self::PERM_LOGGED,
@@ -177,55 +177,69 @@ class LvPlan extends FHCAPI_Controller
$this->terminateWithSuccess($stunden);
}
/**
* fetches room events from a certain date
* @access public
*
*/
/**
* fetches room events from a certain date
* @access public
*
* @return void
*/
public function getRoomplan()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_rules('ort_kurzbz', "Ort", "required");
$this->form_validation->set_rules('start_date', "start_date", "required");
$this->form_validation->set_rules('end_date', "end_date", "required");
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
// storing the post parameter in local variables
$ort_kurzbz = $this->input->post('ort_kurzbz', true);
$start_date = $this->input->post('start_date', true);
$end_date = $this->input->post('end_date', true);
// get data
$this->load->library('StundenplanLib');
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('ort_kurzbz',"Ort","required");
$this->form_validation->set_rules('start_date',"start_date","required");
$this->form_validation->set_rules('end_date',"end_date","required");
if($this->form_validation->run() === FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
// storing the get parameter in local variables
$ort_kurzbz = $this->input->get('ort_kurzbz', TRUE);
$start_date = $this->input->get('start_date', TRUE);
$end_date = $this->input->get('end_date', TRUE);
$roomplan_data = $this->LvPlan->lvPlanGruppierung($this->LvPlan->getRoomQuery($ort_kurzbz, $start_date, $end_date));
$roomplan_data = $this->getDataOrTerminateWithError($roomplan_data);
$this->stundenplanlib->expand_object_information($roomplan_data);
$roomplan_data = $this->stundenplanlib->getRoomplan($ort_kurzbz, $start_date, $end_date);
$roomplan_data = $this->getDataOrTerminateWithError($roomplan_data);
$this->terminateWithSuccess($roomplan_data);
}
// gets the reservierungen of a room if the ort_kurzbz parameter is supplied otherwise gets the reservierungen of the lvplan of a student
public function Reservierungen($ort_kurzbz = null)
/**
* gets the reservierungen of a room if the ort_kurzbz parameter is
* supplied otherwise gets the reservierungen of the lvplan of a student
* @access public
*
* @param string $ort_kurzbz
* @return void
*/
public function getReservierungen($ort_kurzbz = null)
{
$this->load->library('StundenplanLib');
//form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('start_date', "StartDate", "required");
$this->form_validation->set_rules('end_date', "EndDate", "required");
if($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('ressource/Mitarbeiter_model','MitarbeiterModel');
// storing the post parameter in local variables
$start_date = $this->input->post('start_date', true);
$end_date = $this->input->post('end_date', true);
// storing the get parameter in local variables
$start_date = $this->input->get('start_date', TRUE);
$end_date = $this->input->get('end_date', TRUE);
// get data
$this->load->library('StundenplanLib');
$result = $this->stundenplanlib->getReservierungen($start_date, $end_date, $ort_kurzbz);
$result = $this->getDataOrTerminateWithError($result);
$result = $this->stundenplanlib->getReservierungen($start_date,$end_date,$ort_kurzbz);
$result = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($result);
}
+93 -22
View File
@@ -3,6 +3,11 @@
if (! defined('BASEPATH')) exit('No direct script access allowed');
use \DateTime as DateTime;
use \DateTimeZone as DateTimeZone;
use \DateInterval as DateInterval;
use \DatePeriod as DatePeriod;
class StundenplanLib{
private $_ci; // Code igniter instance
@@ -14,9 +19,11 @@ class StundenplanLib{
*
*/
public function getStundenplan($start_date, $end_date, $lv_id = null){
$this->_ci =& get_instance();
// Load Config
$this->_ci->load->config('calendar');
$this->_ci->load->model('ressource/Mitarbeiter_model','MitarbeiterModel');
$this->_ci->load->model('organisation/Studiensemester_model','StudiensemesterModel');
$this->_ci->load->model('education/Studentlehrverband_model', 'StudentlehrverbandModel');
@@ -123,26 +130,69 @@ class StundenplanLib{
}
public function getReservierungen($start_date, $end_date, $ort_kurzbz){
/**
* Get stundenplan for a room
*
* @param string $ort_kurzbz
* @param string $start_date
* @param string $end_date
* @return stdClass
*/
public function getRoomplan($ort_kurzbz, $start_date, $end_date)
{
$this->_ci =& get_instance();
// Load Config
$this->_ci->load->config('calendar');
// Load Models
$this->_ci->load->model('ressource/Stundenplan_model', 'StundenplanModel');
$query = $this->_ci->StundenplanModel->getRoomQuery($ort_kurzbz, $start_date, $end_date);
$roomplan_data = $this->_ci->StundenplanModel->stundenplanGruppierung($query);
if (isError($roomplan_data))
return $roomplan_data;
$this->expand_object_information($roomplan_data->retval);
return $roomplan_data;
}
/**
* Get reservations (for a room or all)
*
* @param string $start_date
* @param string $end_date
* @param string $ort_kurzbz
* @return stdClass
*/
public function getReservierungen($start_date, $end_date, $ort_kurzbz = '')
{
$this->_ci =& get_instance();
// Load Config
$this->_ci->load->config('calendar');
// Load Models
$this->_ci->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$is_mitarbeiter = getData($this->_ci->MitarbeiterModel->isMitarbeiter(getAuthUID()));
if($is_mitarbeiter)
{
if ($is_mitarbeiter) {
$reservierungen = $this->_ci->ReservierungModel->getReservierungenMitarbeiter($start_date, $end_date, $ort_kurzbz);
} else {
// querying the reservierungen
$reservierungen = $this->_ci->ReservierungModel->getReservierungen($start_date, $end_date, $ort_kurzbz);
}
if(isError($reservierungen))
{
return error(getData($reservierungen));
}
$reservierungen = getData($reservierungen) ?? [];
$function_error = $this->expand_object_information($reservierungen);
if(!is_null($function_error)){
if (isError($reservierungen))
return $reservierungen;
$function_error = $this->expand_object_information($reservierungen->retval);
if (!is_null($function_error))
return $function_error;
}
return success($reservierungen);
return $reservierungen;
}
public function getLektorenFromLehrveranstaltung($lehrveranstaltung_id, $semester, $studiengang_kz, $studiensemester_kurzbz){
@@ -181,8 +231,15 @@ class StundenplanLib{
public function expand_object_information($data){
$this->_ci =& get_instance();
foreach ($data as $item)
{
$tz = new DateTimeZone($this->_ci->config->item('timezone'));
$isostart = new DateTime($item->datum . ' ' . $item->beginn, $tz);
$item->isostart = $isostart->format(DateTime::ATOM);
$isoend = new DateTime($item->datum . ' ' . $item->ende, $tz);
$item->isoend = $isoend->format(DateTime::ATOM);
$lektor_obj_array = array();
$gruppe_obj_array = array();
@@ -254,9 +311,17 @@ class StundenplanLib{
}
}
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz){
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
{
$this->_ci =& get_instance();
$this->_ci->load->model('organisation/Ferien_model','FerienModel');
// Load Config
$this->_ci->load->config('calendar');
$this->_ci->load->model('organisation/Ferien_model', 'FerienModel');
$tz = new DateTimeZone($this->_ci->config->item('timezone'));
$ferienEvents = $this->_ci->FerienModel->execReadOnlyQuery("
SELECT *
FROM lehre.tbl_ferien
@@ -277,35 +342,41 @@ class StundenplanLib{
$interval = new DateInterval('P1D');
$period = new DatePeriod($event_start, $interval, $event_end);
$event->dates = array_map(function($date){
return $date->format('Y-m-d');
}, iterator_to_array($period));
$event->dates = iterator_to_array($period);
return $event;
}, $ferienEvents);
$start_date = new DateTime($start_date);
$start_date->setTime(0, 0, 0);
$end_date = new DateTime($end_date);
$end_date->setTime(23, 59, 59);
$ferienEventsFlattened=[];
foreach($ferienEvents as $ferien_event){
foreach($ferien_event->dates as $date){
if ($date < $start_date || $date > $end_date)
continue;
$event = new stdClass();
$event->bezeichnung = $ferien_event->bezeichnung;
$event->datum = $date;
$event->datum = $date->format('Y-m-d');
$event->type = 'ferien';
$ferienEventsFlattened[] = $event;
}
};
$today=new DateTime();
$ferienEventsFlattened = array_map(function($event) use($today){
$ferienEventsFlattened = array_map(function($event) use($today, $tz){
$ferien_event = (object) array(
'type' => 'ferien',
'beginn' => $today->format('H:i:s'),
'ende' => $today->format('H:i:s'),
'isostart' => (new DateTime($event->datum . ' 00:00:00', $tz))->format('c'),
'isoend' => (new DateTime($event->datum . ' 23:59:59', $tz))->format('c'),
'allDayEvent' => true,
'datum' => $event->datum,
'topic' => $event->bezeichnung,
'titel' => $event->bezeichnung,
'farbe' => '00689E'
);
return $ferien_event;
}, $ferienEventsFlattened);
@@ -635,7 +706,7 @@ class StundenplanLib{
{
return $sem->studiensemester_kurzbz;
},
getData($semester_range)
getData($semester_range) ?: []
);
// if no studiensemester is found for the given timespan, get the nearest studiensemester
@@ -15,7 +15,7 @@ $includesArray = array(
'public/css/components/searchbar/searchbar.css',
'public/css/Fhc.css',
'public/css/components/dashboard.css',
'public/css/components/calendar.css',
//'public/css/components/calendar.css', <= imported in dashboard.css
'public/css/components/Sprachen.css',
'public/css/components/MyLv.css',
'public/css/components/FilterComponent.css',
@@ -29,7 +29,8 @@ $includesArray = array(
'vendor/npm-asset/primevue/accordiontab/accordiontab.min.js',
'vendor/npm-asset/primevue/inputnumber/inputnumber.min.js',
'vendor/npm-asset/primevue/textarea/textarea.min.js',
'vendor/npm-asset/primevue/checkbox/checkbox.min.js'
'vendor/npm-asset/primevue/checkbox/checkbox.min.js',
'vendor/moment/luxonjs/luxon.min.js'
),
'customJSModules' => array(
'public/js/apps/Dashboard/Fhc.js'
@@ -39,7 +40,7 @@ $includesArray = array(
$this->load->view('templates/CISVUE-Header', $includesArray);
?>
<div id="fhccontent" route=<?php echo $route ?>>
<div id="fhccontent" class="h-100" route=<?php echo $route ?>>
<router-view
:view-data='<?php echo json_encode($viewData) ?>'
></router-view>
+12
View File
@@ -414,6 +414,17 @@
}
}
},
{
"type": "package",
"package": {
"name": "moment/luxonjs",
"version": "3.6.1",
"dist": {
"url": "https://unpkg.com/luxon@3.6.1/build/global/luxon.min.js",
"type": "file"
}
}
},
{
"type": "composer",
"url": "https://asset-packagist.org"
@@ -462,6 +473,7 @@
"michelf/php-markdown": "1.5.0",
"ml/json-ld": "1.*",
"moment/luxonjs": "3.6.1",
"moment/momentjs": "2.24.0",
"mottie/tablesorter": "2.*",
Generated
+10 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1b8a408a43c62cb966fa89af338564a7",
"content-hash": "1de37a74ba51a66057eb2712b21340c8",
"packages": [
{
"name": "afarkas/html5shiv",
@@ -1296,6 +1296,15 @@
},
"time": "2022-09-29T08:45:17+00:00"
},
{
"name": "moment/luxonjs",
"version": "3.6.1",
"dist": {
"type": "file",
"url": "https://unpkg.com/luxon@3.6.1/build/global/luxon.min.js"
},
"type": "library"
},
{
"name": "moment/momentjs",
"version": "2.24.0",
+11 -11
View File
@@ -143,27 +143,27 @@
.dayPageContainer .feiertagEventContent #ferienEventTitle {}
/* Minimized Container Styles */
/* List Page Container Styles */
/* Ferientage Events for Minimized */
/* Ferientage Events for List Page */
.minimizedContainer .feiertagEventContent{
.listPageContainer .feiertagEventContent{
display: flex;
justify-content: center;
align-items: center;
color: var(--fhc-light);
}
.minimizedContainer .feiertagEventContent #ferienEventIcon{
.listPageContainer .feiertagEventContent #ferienEventIcon{
margin: 0.5rem;
}
.minimizedContainer .feiertagEventContent #ferienEventTitle{
.listPageContainer .feiertagEventContent #ferienEventTitle{
flex-grow: 1;
}
/* Lehreinheits Events in Minimized */
.dayPageContainer .lehreinheitEventContent {
/* Lehreinheits Events in List Page */
.listPageContainer .lehreinheitEventContent {
display: flex;
padding-left: 4rem;
justify-content: space-evenly;
@@ -171,15 +171,15 @@
}
.dayPageContainer .lehreinheitEventContent #lehreinheitEventHeader {
.listPageContainer .lehreinheitEventContent #lehreinheitEventHeader {
display: grid;
align-content: center;
}
.dayPageContainer .lehreinheitEventContent #lehreinheitEventText {
.listPageContainer .lehreinheitEventContent #lehreinheitEventText {
display: contents;
}
.dayPageContainer .lehreinheitEventContent #lehreinheitEventText * {
.listPageContainer .lehreinheitEventContent #lehreinheitEventText * {
font-weight: 500;
}
}
+31 -434
View File
@@ -1,441 +1,38 @@
:root{
--fhc-calendar-pane-height: calc(100vh - 220px);
--fhc-calendar-primary: var(--fhc-primary, #006095);
@import './calendar/frame.css';
@import './calendar/events.css';
@import './calendar/backgrounds.css';
/* Themable Variables */
:root {
--fhc-calendar-gap: var(--fhc-border, #dee2e6);
--fhc-calendar-bg: var(--fhc-background, #fff);
--fhc-calendar-border: var(--fhc-border, #dee2e6);
--fhc-calendar-border-highlight: var(--fhc-border-highlight, #495057);
--fhc-calendar-text: var(--fhc-text, #212529);
--fhc-calendar-text-light: var(--fhc-text, #212529);
--fhc-calendar-background: var(--fhc-background, #fff);
--fhc-calendar-dark: var(--fhc-dark, #212529);
--fhc-calendar-hour-indicator-bg: var(--fhc-background, #fff);
--fhc-calendar-week-page-header-background: var(--fhc-background, #fff);
--fhc-calendar-week-page-header-color: var(--fhc-text, #212529);
--fhc-calendar-week-page-header-border: var(--fhc-border, #dee2e6);
--fhc-calendar-week-page-header-hover-background: var(--fhc-background-highlight, #d5dae0);
--fhc-calendar-all-day-event-background: var(--fhc-background, #fff);
--fhc-calendar-past: var(--fhc-beige-10, rgba(245, 233, 215, 0.5));
--fhc-calendar-box-shadow: var(--fhc-box-shadow, #dee2e6);
}
:root.dark{
--fhc-calendar-past: var(--fhc-beige-20, rgba(172, 153, 125, 0.5));
}
--fhc-calendar-bg-event: var(--fhc-white-80, #ccc);
--fhc-calendar-border-event: var(--bs-secondary, #6c757d);
--fhc-calendar-radius-event: var(--bs-border-radius, .375rem);
--fhc-calendar-fontsize-event: .875em;
.fhc-calendar-pane {
height: var(--fhc-calendar-pane-height);
}
--fhc-calendar-bg-markings-past: var(--fhc-beige-10, rgba(245, 233, 215, 0.5));
--fhc-calendar-border-markings-past: var(--fhc-calendar-border);
--fhc-calendar-bg-markings-past-label: var(--fhc-calendar-bg);
--fhc-calendar-border-markings-past-label: var(--fhc-calendar-border);
.fhc-calendar-hour-indicator{
border-top: 1px solid var(--fhc-calendar-border);
}
--fhc-calendar-gap-header: transparent;
--fhc-calendar-gap-allday: transparent;
--fhc-calendar-gap-body: transparent;
--fhc-calendar-bg-header: var(--fhc-calendar-bg);
--fhc-calendar-bg-allday: var(--fhc-calendar-bg);
--fhc-calendar-bg-body: var(--fhc-calendar-bg);
.fhc-calendar-hour-indicator span{
background-color: var(--fhc-calendar-hour-indicator-bg) !important;
}
.fhc-calendar-week-page-header{
color: var(--fhc-calendar-text);
background-color: var(--fhc-calendar-week-page-header-background);
}
.fhc-calendar-week-page-header > div {
border-right: 1px solid var(--fhc-calendar-border);
}
.fhc-calendar-week-page-header > div:hover{
background-color: var(--fhc-calendar-week-page-header-hover-background);
}
.fhc-calendar-months .col-4,
.fhc-calendar-years .col-4 {
padding: 0.1875em 0;
}
.show-weeks .fhc-calendar-months .col-4,
.show-weeks .fhc-calendar-years .col-4 {
padding: 0.1875em 0.25em;
}
.fhc-calendar-months .col-4 button,
.fhc-calendar-years .col-4 button {
aspect-ratio: 28/18;
}
.fhc-calendar-month-page {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: 1.5em repeat(6, 1fr);
}
.fhc-calendar-month-page.show-weeks {
grid-template-columns: 1.5em repeat(7, 1fr);
grid-template-rows: 1.5em repeat(6, 1fr);
}
.fhc-calendar-month-page-weekday,
.fhc-calendar-month-page-day {
color: inherit;
}
.fhc-calendar-week .carousel-item {
/*padding: 0.75em;*/
}
.fhc-calendar-week-page {
/*aspect-ratio: 7/6;*/
min-height: 0;
}
.fhc-calendar-week-page > div {
/*transform: translate(-0.75em, -0.75em);*/
/*width: calc(100% + 1.5em);*/
/*height: calc(100% + 1.5em);*/
max-height: 100%;
}
.fhc-calendar-week-page > div > div {
padding-left: 3em;
}
.fhc-calendar-week-page .events {
display: grid;
grid-template-columns: 3em repeat(7, 1fr);
margin-left: -3em;
/*min-height: 266.6666666667%;*/
}
.fhc-calendar-week-page .events .day {
gap: 1px;
}
.fhc-calendar-week-page .events .hours, .fhc-calendar-week-page .events .day {
display: grid;
}
.fhc-calendar-week-page .all-day-event-border{
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
.fhc-calendar-week-page .all-day-event {
max-height: 75px;
overflow: auto;
overscroll-behavior: none;
}
.fhc-calendar-week-page .all-day-event-container{
position: sticky;
top: 44px;
display:grid;
grid-template-columns:repeat(7,1fr);
width:100%;
z-index:3;
background-color:var(--fhc-calendar-all-day-event-background);
}
.fhc-calendar-week-page .all-day-event-container::before {
position: absolute;
content:'';
top: 0;
bottom: 0;
left: -3em;
right: 100%;
background-color: var(--fhc-calendar-all-day-event-background);
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
.fhc-calendar-day-page .all-day-event-border {
box-shadow: 0 0 0 1px var(--fhc-calendar-box-shadow);
}
.fhc-calendar-day-page .all-day-event {
grid-column:2;
max-height: 75px;
overflow: auto;
}
.fhc-calendar-day-page .all-day-event-container {
position: sticky;
top:0;
display: grid;
grid-template-columns: 3em 1fr;
width: 100%;
z-index: 3;
background-color:var(--fhc-calendar-all-day-event-background);
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
/* grid hour lines of the Stundenplan use box-shadow instead of border because box-shadow renders consistently on different viewports*/
.box-shadow-border{
box-shadow: 0 0 0 1px var(--fhc-calendar-border) !important;
}
.fhc-calendar-no-events-overlay{
position: relative;
}
.fhc-calendar-no-events-overlay::before {
content: "";
position: absolute;
top: 0;
left: 3rem;
margin:auto;
width: 100%;
height: 100%;
background-image: linear-gradient(120deg, var(--fhc-calendar-background), var(--fhc-calendar-dark) );
opacity: .7;
}
.fhc-calendar-day-page {
/*aspect-ratio: 7/6;*/
min-height: 0;
}
.fhc-calendar-day-page > div {
height: calc(100% + 1.5em);
}
.fhc-calendar-day-page .flex-column > .flex-grow-1 {
padding-left: 3em;
}
.fhc-calendar-day-page .events {
display: grid;
grid-template-columns: 3em 1fr;
}
.fhc-calendar-day-page .events .day {
gap: 1px;
}
.fhc-calendar-day-page .events .hours, .fhc-calendar-day-page .events .day {
display: grid;
}
.fhc-calendar-month-page-day{
color: var(--fhc-calendar-text) !important;
}
.fhc-calendar-month-page-day.fhc-calendar-past:not(.fhc-calendar-month-page-day-focusday) {
color: var(--fhc-calendar-text) !important;
}
.fhc-calendar-lg .fhc-calendar-month-page-day,
.fhc-calendar-md .fhc-calendar-month-page-day {
border: solid 1px var(--fhc-calendar-border);
}
.fhc-calendar-lg .fhc-calendar-month-page-weekday,
.fhc-calendar-md .fhc-calendar-month-page-weekday{
border: solid 1px var(--fhc-secondary);
}
.fhc-calendar-lg .fhc-calendar-month-page-day,
.fhc-calendar-md .fhc-calendar-month-page-day {
aspect-ratio: 1;
display: flex;
flex-direction: column;
}
.fhc-calendar-lg .fhc-calendar-month-page-day.active,
.fhc-calendar-md .fhc-calendar-month-page-day.active {
border-color: var(--fhc-calendar-border-highlight);
}
/*.fhc-calendar-lg .fhc-calendar-month-page-day .events,*/
/*.fhc-calendar-md .fhc-calendar-month-page-day .events {*/
/* display: block;*/
/* overflow: auto;*/
/* font-size: 0.7em;*/
/*}*/
/*.fhc-calendar-lg .fhc-calendar-month-page-day .events span,*/
/*.fhc-calendar-md .fhc-calendar-month-page-day .events span {*/
/* display: block;*/
/* margin: 0.2em;*/
/* padding: 0.1em 0.4em;*/
/* border-radius: 0.1em;*/
/*}*/
.fhc-calendar-lg .fhc-calendar-years .col-4,
.fhc-calendar-md .fhc-calendar-years .col-4 {
padding: 0.09375em 0;
}
.show-weeks .fhc-calendar-lg .fhc-calendar-years .col-4,
.show-weeks .fhc-calendar-md .fhc-calendar-years .col-4 {
padding: 0.09375em 0.25em;
}
.fhc-calendar-lg .fhc-calendar-years .col-4 button,
.fhc-calendar-md .fhc-calendar-years .col-4 button {
aspect-ratio: 28/9;
}
.fhc-calendar-xs {
font-size: 0.6em;
}
.fhc-calendar-sm .fhc-calendar-month-page {
font-size: 0.8em;
}
.fhc-calendar-sm .fhc-calendar-month-page-weekday,
.fhc-calendar-xs .fhc-calendar-month-page-weekday {
font-style: italic;
display: flex;
align-items: center;
justify-content: end;
}
.fhc-calendar-month-page-weekday:hover {
background-color:var(--fhc-calendar-primary);
color:var(--fhc-calendar-text-light);
--fhc-calendar-bg-month-today: var(--fhc-primary);
--fhc-calendar-color-month-today: var(--fhc-white-10);
--fhc-calendar-bg-overlay1: var(--fhc-calendar-bg);
--fhc-calendar-bg-overlay2: var(--fhc-dark, #212529);
--fhc-calendar-radius-day-event: var(--fhc-calendar-radius-event);
--fhc-calendar-radius-week-event: var(--fhc-calendar-radius-event);
--fhc-calendar-radius-month-event: 0;
--fhc-calendar-radius-list-event: var(--fhc-calendar-radius-event);
}
.fhc-calendar-sm .fhc-calendar-month-page-day,
.fhc-calendar-xs .fhc-calendar-month-page-day {
position: relative;
aspect-ratio: 1;
}
.fhc-calendar-past {
background-color: var(--fhc-calendar-past);
border-color: var(--fhc-calendar-border);
opacity: 0.75;
}
.fhc-calendar-month-page-day-focusday {
border-color: var(--fhc-calendar-border-highlight) !important;
background-color: var(--fhc-calendar-background);
position: relative;
animation: dash-animation 2.5s linear infinite;
}
@keyframes dash-animation {
0% {
border-style: dashed;
}
50% {
border-style: solid;
}
100% {
border-style: dashed;
}
}
.fhc-highlight-week {
/*border-color: black !important;*/
}
.fhc-highlight-day {
position:relative;
}
.fhc-highlight-day::before {
content:'';
position:absolute;
top:0;
bottom:0;
left:0;
right:0;
box-shadow: inset 0 0 0 2px var(--fhc-calendar-border) !important;
pointer-events: none;
z-index: 2;
}
.fhc-calendar-sm .fhc-calendar-month-page-day.active .no,
.fhc-calendar-xs .fhc-calendar-month-page-day.active .no {
background-color: var(--fhc-calendar-primary);
border-radius: 50%;
font-weight: bold;
}
.fhc-calendar-sm .fhc-calendar-month-page-day:not(.active):hover .no,
.fhc-calendar-xs .fhc-calendar-month-page-day:not(.active):hover .no {
background-color: var(--fhc-calendar-primary);
border-radius: 50%;
}
/*.fhc-calendar-sm .fhc-calendar-month-page-day .no,*/
/*.fhc-calendar-xs .fhc-calendar-month-page-day .no {*/
/* display: flex;*/
/* align-items: center;*/
/* justify-content: center;*/
/* width: 80%;*/
/* height: 80%;*/
/* margin: 10%;*/
/*}*/
/*.fhc-calendar-sm .fhc-calendar-month-page-day .events,*/
/*.fhc-calendar-xs .fhc-calendar-month-page-day .events {*/
/* position: absolute;*/
/* bottom: 0;*/
/* left: 10%;*/
/* width: 80%;*/
/* height: 10%;*/
/* overflow: hidden;*/
/* display: flex;*/
/*}*/
.fhc-calendar-sm .fhc-calendar-month-page-day .events span,
.fhc-calendar-xs .fhc-calendar-month-page-day .events span {
overflow: hidden;
}
.selectedEvent {
background-color: var(--fhc-calendar-primary) !important;
color: var(--fhc-calendar-text-light);
}
.fhc-calendar-day-page .curTimeIndicator{
position: absolute;
left: 0;
right: 0;
pointer-events: none;
padding-left: 7rem;
margin-top: -1px;
z-index: 2;
border-color: var(--fhc-calendar-border)!important;
}
.fhc-calendar-week-page .curTimeIndicator {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
padding-left: 1rem;
margin-top: -1px;
z-index: 2;
border-color: var(--fhc-calendar-border) !important;
}
.fhc-calendar-week-page .past::before{
content:"";
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.fhc-calendar-week-page .overlay {
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
}
.fhc-calendar-day-page .overlay {
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
}
.dayPageContainer,
.weekPageContainer {
container-type: inline-size;
}
#calendarHeaderTitle{
color: var(--fhc-text);
}
.fhc-calendar-day-page .hours,
.fhc-calendar-day-page .curTimeIndicator,
.fhc-calendar-day-page .fhc-calendar-hour-indicator,
.fhc-calendar-week-page .hours,
.fhc-calendar-week-page .curTimeIndicator,
.fhc-calendar-week-page .fhc-calendar-hour-indicator,
.fhc-calendar-day-page .date,
.fhc-calendar-week-page .date {
color: var(--fhc-text-secondary) !important;
}
@@ -0,0 +1,18 @@
.background-past {
background-color: var(--fhc-calendar-bg-markings-past);
border-bottom: solid 1px var(--fhc-calendar-border-markings-past);
}
.background-past > span {
display: none;
}
.background-past.bg-end > span {
display: block;
position: absolute;
top: 100%;
left: 1rem;
padding: 0 .5rem;
background-color: var(--fhc-calendar-bg-markings-past-label);
border: solid 1px var(--fhc-calendar-border-markings-past-label);
font-size: .875em;
}
+70
View File
@@ -0,0 +1,70 @@
.event {
cursor: pointer;
}
.fhc-calendar-mode-week .all-day-events .event .weekPageContainer {
padding: .25rem;
}
.fhc-calendar-mode-day .event,
.fhc-calendar-mode-week .event {
margin: 0 .5rem;
font-size: var(--fhc-calendar-fontsize-event, .875rem);
}
.fhc-calendar-mode-day .all-day-events .event,
.fhc-calendar-mode-week .all-day-events .event {
margin: .25rem;
}
/* Border */
.fhc-calendar-mode-day .grid-main .event > *,
.fhc-calendar-mode-week .grid-main .event > * {
border: solid 1px var(--fhc-calendar-border-event);
}
.fhc-calendar-mode-day .event {
--event-radius: var(--fhc-calendar-radius-day-event, 0);
}
.fhc-calendar-mode-week .event {
--event-radius: var(--fhc-calendar-radius-week-event, 0);
}
.fhc-calendar-mode-month .event {
--event-radius: var(--fhc-calendar-radius-month-event, 0);
}
.fhc-calendar-mode-list .event {
--event-radius: var(--fhc-calendar-radius-list-event, 0);
}
.fhc-calendar-mode-day .all-day-events .event > *,
.fhc-calendar-mode-week .all-day-events .event > *,
.fhc-calendar-mode-day .event.event-begin > *,
.fhc-calendar-mode-week .event.event-begin > * {
border-top-left-radius: var(--event-radius);
border-top-right-radius: var(--event-radius);
}
.fhc-calendar-mode-day .all-day-events .event > *,
.fhc-calendar-mode-week .all-day-events .event > *,
.fhc-calendar-mode-day .event.event-end > *,
.fhc-calendar-mode-week .event.event-end > * {
border-bottom-left-radius: var(--event-radius);
border-bottom-right-radius: var(--event-radius);
}
.fhc-calendar-mode-month .event.event-begin > * {
border-top-left-radius: var(--event-radius);
border-bottom-left-radius: var(--event-radius);
}
.fhc-calendar-mode-month .event.event-end > * {
border-top-right-radius: var(--event-radius);
border-bottom-right-radius: var(--event-radius);
}
.fhc-calendar-mode-list .event > * {
border-radius: var(--event-radius);
}
/* Colors */
.fhc-calendar-base .event {
--event-default-bg: var(--fhc-calendar-bg-event, #ccc);
}
.fhc-calendar-base .event .event-type-reservierung {
--event-default-bg: var(--fhc-calendar-bg-reservierung, #fff);
}
.fhc-calendar-base .event > * {
background-color: var(--event-bg, var(--event-default-bg));
}
.fhc-calendar-base .event > *:hover {
filter: brightness(120%);
}
+180
View File
@@ -0,0 +1,180 @@
/* Labels */
/* ====== */
.fhc-calendar-base-label-dow .short,
.fhc-calendar-base-label-dow .narrow,
.fhc-calendar-base-label-day .full,
.fhc-calendar-base-label-day .short,
.fhc-calendar-base-label-day .narrow,
.fhc-calendar-mode-month .fhc-calendar-base-label-day .long,
.collapsed-header .fhc-calendar-base-label-day .long,
.collapsed-header .fhc-calendar-base-label-dow .long {
display: none;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-day .narrow,
.collapsed-header .fhc-calendar-base-label-day .short,
.collapsed-header .fhc-calendar-base-label-dow .short {
display: block;
}
.fhc-calendar-mode-day .fhc-calendar-base-label-day,
.fhc-calendar-mode-week .fhc-calendar-base-label-day {
font-size: .875em;
}
.fhc-calendar-mode-day .main-header,
.fhc-calendar-mode-week .main-header {
text-align: center;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-week {
text-align: right;
}
.fhc-calendar-base-label-week {
margin-left: .5rem;
}
.fhc-calendar-base-label-time,
.fhc-calendar-base-label-day,
.fhc-calendar-base-label-week {
color: var(--bs-secondary-color);
}
.fhc-calendar-mode-day .fhc-calendar-base-label-dow,
.fhc-calendar-mode-week .fhc-calendar-base-label-dow,
.fhc-calendar-base-label-day,
.fhc-calendar-base-label-week {
cursor: pointer;
}
.fhc-calendar-base-label-week span + span:before {
content: "/";
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event.event-header {
height: 100%;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-day {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event.event-header {
--event-bg: transparent;
font-weight: bold;
text-indent: 0;
font-size: var(--fhc-calendar-fontsize-month-day, 1rem);
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event.event-header .disabled {
opacity: .3;
}
/* Base Grid */
/* ========= */
/* Grid */
.fhc-calendar-base-grid {
--fhc-calendar-axis-collapsible: .2fr;
background-color: var(--fhc-calendar-gap);
grid-gap: 2px;
}
.fhc-calendar-base-grid .grid-header {
background-color: var(--fhc-calendar-gap-header);
}
.fhc-calendar-base-grid .grid-header:before,
.fhc-calendar-base-grid .grid-allday:before {
content: "";
}
.fhc-calendar-base-grid .grid-header:before,
.fhc-calendar-base-grid .grid-header .main-header {
background-color: var(--fhc-calendar-bg-header);
}
.fhc-calendar-base-grid .grid-allday {
background-color: var(--fhc-calendar-gap-allday);
}
.fhc-calendar-base-grid .grid-allday:before,
.fhc-calendar-base-grid .grid-allday .all-day-events {
background-color: var(--fhc-calendar-bg-allday);
}
.fhc-calendar-base-grid .grid-main {
grid-gap: 1px;
background-color: var(--fhc-calendar-gap-body);
}
.fhc-calendar-base-grid .grid-main .part-header,
.fhc-calendar-base-grid .grid-main .part-body {
background-color: var(--fhc-calendar-bg-body);
}
.fhc-calendar-base-grid-line {
grid-gap: 1px;
}
.fhc-calendar-base-label-time {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: end;
}
/* Header */
.fhc-calendar-base-header > .row {
align-items: center;
}
/* Modes */
/* ===== */
/* Day */
.fhc-calendar-mode-day .event-details {
padding: 1.5rem;
width: 100%;
border-left: solid 1px var(--fhc-calendar-gap);
}
.fhc-calendar-mode-day .today {
background-color: var(--fhc-calendar-bg-day-today);
color: var(--fhc-calendar-color-day-today);
}
/* Week */
.fhc-calendar-mode-week .today {
background-color: var(--fhc-calendar-bg-week-today);
color: var(--fhc-calendar-color-week-today);
}
/* Month */
.fhc-calendar-mode-month .fhc-calendar-base-grid-line {
overflow: hidden;
line-height: 1.5;
grid-template-rows: 2em;
grid-auto-rows: calc(var(--fhc-calendar-fontsize-event, .875rem) + 2 * var(--fhc-calendar-margin-event-icon, .5rem));
font-size: var(--fhc-calendar-fontsize-event, .875rem);
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event {
text-indent: .5em;
height: calc(var(--fhc-calendar-fontsize-event, .875rem) + 2 * var(--fhc-calendar-margin-event-icon, .5rem));
overflow: hidden;
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event .placeholder-glow {
text-indent: 0;
}
.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event * {
max-height: 100%;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fhc-calendar-mode-month .event > .today {
background-color: var(--fhc-calendar-bg-month-today);
color: var(--fhc-calendar-color-month-today);
}
/* List */
.fhc-calendar-mode-list .today {
background-color: var(--fhc-calendar-bg-list-today);
color: var(--fhc-calendar-color-list-today);
}
/* Overlays */
/* ======== */
/* No Events */
.fhc-calendar-no-events-overlay {
background: linear-gradient(120deg, var(--fhc-calendar-bg-overlay1), var(--fhc-calendar-bg-overlay2));
opacity: .7;
text-align: center;
font-size: 2.5rem;
font-weight: 500;
line-height: 1.2;
}
.fhc-calendar-no-events-overlay:before {
content: "";
display: block;
height: 100px;
}
+1
View File
@@ -1,5 +1,6 @@
@import './calendar.css';
@import './dashboard/news.css';
@import './dashboard/LvPlan.css';
:root{
--fhc-dashboard-danger: var(--fhc-danger, #842029);
@@ -0,0 +1,27 @@
.dashboard-widget-lvplan .fhc-calendar-base .card {
--bs-card-cap-padding-x: 0;
--bs-card-cap-padding-y: 0;
--bs-card-border-radius: 0;
--bs-card-border-width: 0;
--dp-input-padding: .375rem .75rem;
--dp-input-icon-padding: calc(1rem + 24px);
}
.dashboard-widget-lvplan .fhc-calendar-base .card-header {
border-bottom-width: var(--bs-border-width);
}
.dashboard-widget-lvplan .fhc-calendar-base .card-header > .row > .col-auto,
.dashboard-widget-lvplan .fhc-calendar-base .card-header .btn-group {
width: 100%;
}
.dashboard-widget-lvplan .fhc-calendar-base .card-header input {
--dp-font-size: .7em;
}
.dashboard-widget-lvplan .fhc-calendar-base .card-header .btn {
--bs-btn-font-size: .7em;
}
.dashboard-widget-lvplan .fhc-calendar-base-label-day {
cursor: initial;
}
.dashboard-widget-lvplan .fhc-calendar-mode-list-view {
padding: 0 1rem;
}
+5 -5
View File
@@ -18,7 +18,7 @@
export default {
getRoomInfo(ort_kurzbz, start_date, end_date) {
return {
method: 'get',
method: 'post',
url: '/api/frontend/v1/LvPlan/getRoomplan',
params: { ort_kurzbz, start_date, end_date }
};
@@ -39,15 +39,15 @@ export default {
},
getOrtReservierungen(ort_kurzbz, start_date, end_date) {
return {
method: 'get',
url: `/api/frontend/v1/LvPlan/Reservierungen/${ort_kurzbz}`,
method: 'post',
url: `/api/frontend/v1/LvPlan/getReservierungen/${ort_kurzbz}`,
params: { start_date, end_date }
};
},
getLvPlanReservierungen(start_date, end_date) {
return {
method: 'get',
url: '/api/frontend/v1/LvPlan/Reservierungen',
method: 'post',
url: '/api/frontend/v1/LvPlan/getReservierungen',
params: { start_date, end_date }
};
},
+5 -5
View File
@@ -1,7 +1,7 @@
export default {
getRoomInfo(ort_kurzbz, start_date, end_date) {
return this.$fhcApi.get(
return this.$fhcApi.post(
'/api/frontend/v1/LvPlan/getRoomplan',
{ ort_kurzbz, start_date, end_date}
);
@@ -13,14 +13,14 @@ export default {
);
},
getOrtReservierungen(ort_kurzbz, start_date, end_date) {
return this.$fhcApi.get(
`/api/frontend/v1/LvPlan/Reservierungen/${ort_kurzbz}`,
return this.$fhcApi.post(
`/api/frontend/v1/LvPlan/getReservierungen/${ort_kurzbz}`,
{ start_date, end_date}
);
},
getLvPlanReservierungen(start_date, end_date) {
return this.$fhcApi.get(
'/api/frontend/v1/LvPlan/Reservierungen',
return this.$fhcApi.post(
'/api/frontend/v1/LvPlan/getReservierungen',
{ start_date, end_date }
);
},
+293
View File
@@ -0,0 +1,293 @@
import BaseDraganddrop from './Base/DragAndDrop.js';
import BaseHeader from './Base/Header.js';
import BaseSlider from './Base/Slider.js';
import BsModal from '../Bootstrap/Modal.js';
import CalClick from '../../directives/Calendar/Click.js';
export default {
name: "CalendarBase",
components: {
BaseDraganddrop,
BaseHeader,
BaseSlider,
BsModal
},
directives: {
CalClick
},
provide() {
return {
locale: Vue.computed(() => this.locale),
timezone: Vue.computed(() => this.timezone),
timeGrid: Vue.computed(() => this.timeGrid),
draggableEvents: Vue.computed(() => {
if (!this.draggableEvents)
return () => false;
if (Array.isArray(this.draggableEvents))
return event => this.draggableEvents.includes(event.type);
if (this.draggableEvents instanceof Function)
return this.draggableEvents;
return () => true;
}),
dropableEvents: Vue.computed(() => {
if (!this.onDrop)
return () => false;
if (Array.isArray(this.dropableEvents))
return item => this.dropableEvents.includes(item.type);
if (this.dropableEvents instanceof Function)
return this.dropableEvents;
return () => true;
}),
hasDragoverFunc: Vue.computed(() => this.onDragover),
mode: Vue.computed(() => this.mode)
};
},
props: {
locale: {
type: String,
default: 'de'
},
timezone: {
type: String,
required: true
},
date: {
type: [Date, String, Number, luxon.DateTime],
default: luxon.DateTime.local()
},
modes: {
type: Object,
required: true,
default: {}
// TODO(chris): verfication functions
},
mode: String,
modeOptions: Object,
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
},
showBtns: Boolean,
btnMonth: {
type: Boolean,
default: undefined
},
btnWeek: {
type: Boolean,
default: undefined
},
btnDay: {
type: Boolean,
default: undefined
},
btnList: {
type: Boolean,
default: undefined
},
timeGrid: Array,
draggableEvents: [Boolean, Array, Function],
dropableEvents: [Boolean, Array, Function],
onDragover: Function,
onDrop: Function
},
emits: [
"click:next",
"click:prev",
"click:mode",
"click:event",
"click:day",
"click:week",
"update:date",
"update:mode",
"update:range",
"drop"
],
data() {
return {
internalView: null,
internalDate: null,
modalEvent: null
};
},
computed: {
convertedEvents() {
return this.events.map(orig => ({
id: orig.type + orig[orig.type + '_id'],
type: orig.type,
start: luxon.DateTime.fromISO(orig.isostart).setZone(this.timezone),
end: luxon.DateTime.fromISO(orig.isoend).setZone(this.timezone),
orig
}));
},
convertedBackgrounds() {
return this.backgrounds.map(bg => {
const res = { ...bg };
if (res.start) {
if (Number.isInteger(res.start))
res.start = luxon.DateTime.fromMillis(res.start, { zone: this.timezone, locale: this.locale });
else if (res.start instanceof Date)
res.start = luxon.DateTime.fromJSDate(res.start, { zone: this.timezone, locale: this.locale });
else if (typeof res.start ===
'string' || res.start instanceof String)
res.start = luxon.DateTime.fromISO(res.start, { zone: this.timezone, locale: this.locale });
}
if (res.end) {
if (Number.isInteger(res.end))
res.end = luxon.DateTime.fromMillis(res.end, { zone: this.timezone, locale: this.locale });
else if (res.end instanceof Date)
res.end = luxon.DateTime.fromJSDate(res.end, { zone: this.timezone, locale: this.locale });
else if (typeof res.end ===
'string' || res.end instanceof String)
res.end = luxon.DateTime.fromISO(res.end, { zone: this.timezone, locale: this.locale });
}
return res;
});
},
cDate: {
get() {
if (this.internalDate) {
return this.internalDate.setLocale(this.locale);
}
return luxon.DateTime.fromJSDate(new Date(this.date)).setZone(this.timezone).setLocale(this.locale);
},
set(value) {
this.internalDate = value;
this.$emit('update:date', value);
}
},
cMode: {
get() {
if (!this.internalView) {
// choose default mode
let mode = this.mode;
if (!mode || !this.modes[mode])
mode = Object.keys(this.modes).find(Boolean); // start with first entry as active mode
return mode || '';
}
return this.internalView;
},
set(value) {
this.internalView = value;
this.$emit('update:mode', value);
}
}
},
methods: {
clickPrev() {
const evt = new Event('click:prev', {cancelable: true});
this.$emit('click:prev', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.prevPage();
},
clickNext() {
const evt = new Event('click:next', {cancelable: true});
this.$emit('click:next', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.nextPage();
},
handleClickDefaults(evt) {
// TODO(chris): implement
switch (evt.detail.source) {
case 'day':
if (this.cMode != 'day' && this.modes['day']) {
evt.stopPropagation();
this.cDate = evt.detail.value;
this.cMode = 'day';
}
break;
case 'week':
if (this.cMode != 'week' && this.modes['week']) {
evt.stopPropagation();
this.cDate = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.cDate.zoneName,
locale: this.cDate.locale
});
this.cMode = 'week';
}
break;
}
},
onDropItem(evt, start, end) {
this.$emit('drop', evt, start, end);
},
showEventModal(eventObj) {
this.modalEvent = eventObj;
this.$refs.modal.show();
},
hideEventModal() {
if (this.modalEvent)
this.modalEvent.closeFn = undefined;
this.$refs.modal.hide();
this.modalEvent = null;
},
onModalHidden() {
if (this.modalEvent.closeFn)
this.modalEvent.closeFn();
}
},
template: /* html */`
<div class="fhc-calendar-base h-100">
<base-draganddrop
class="card h-100"
:events="convertedEvents"
:backgrounds="convertedBackgrounds"
@drop="onDropItem"
v-cal-click:container
@cal-click-default.capture="handleClickDefaults"
>
<base-header
class="card-header"
v-model:date="cDate"
v-model:mode="cMode"
@prev="clickPrev"
@next="clickNext"
@click:mode="$emit('click:mode', $event)"
:btn-day="!!modes['day'] && (btnDay || (showBtns && btnDay !== false))"
:btn-week="!!modes['week'] && (btnWeek || (showBtns && btnWeek !== false))"
:btn-month="!!modes['month'] && (btnMonth || (showBtns && btnMonth !== false))"
:btn-list="!!modes['list'] && (btnList || (showBtns && btnList !== false))"
:mode-options="modeOptions ? modeOptions[cMode] : undefined"
>
<slot name="actions" />
</base-header>
<component
:is="modes ? modes[cMode] : null || 'div'"
ref="mode"
v-model:current-date="cDate"
@update:range="$emit('update:range', $event)"
@request-modal-open="showEventModal"
@request-modal-close="hideEventModal"
v-bind="modeOptions ? modeOptions[cMode] : null || {}"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
</component>
</base-draganddrop>
<bs-modal ref="modal" dialog-class="modal-lg" body-class="" @hidden-bs-modal="onModalHidden">
<template #title>
<slot v-if="modalEvent" v-bind="{mode: 'eventheader', event: modalEvent.event}" />
</template>
<template #default>
<slot v-if="modalEvent" v-bind="{mode: 'event', event: modalEvent.event}" />
</template>
</bs-modal>
</div>
`
}
@@ -0,0 +1,162 @@
import DragAndDrop from '../../../helpers/DragAndDrop.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
/**
* TODO(chris): this needs serious rework!
*/
export default {
name: "CalendarDragAndDrop",
directives: {
CalDnd
},
provide() {
return {
events: Vue.computed(() => this.correctedEvents),
backgrounds: Vue.computed(() => this.backgrounds),
dropAllowed: Vue.computed(() => this.dragging && this.dropAllowed)
};
},
inject: {
mode: "mode",
dropableEvents: "dropableEvents"
},
props: {
events: Array,
backgrounds: Array
},
emits: [
"drop"
],
data() {
return {
dragging: false,
allowed: false,
draggedInternalEvent: null,
draggedExternalEvent: null,
targetTimestamp: 0,
targetGridEnds: null,
dropAllowed: false,
shadowPreview: false // TODO(chris): IMPLEMENT! (use background instead of event as preview)
};
},
computed: {
correctedEvents() {
if (this.dragging) {
if (this.draggedInternalEvent) {
const index = this.events.findIndex(e => e.id == this.draggedInternalEvent.id);
if (this.previewEvent && !this.shadowPreview)
return this.events.toSpliced(index, 1, this.previewEvent);
else
return this.events.toSpliced(index, 1);
}
if (this.previewEvent && !this.shadowPreview)
return [...this.events, this.previewEvent];
}
return this.events;
},
correctedBackgrounds() {
if (this.dragging) {
if (this.shadowPreview) {
// TODO(chris): how to get the length
return [...this.backgrounds, {
start: new Date(this.targetTimestamp),
class: 'shadow-preview'
}];
}
}
return this.backgrounds;
},
previewEvent() {
if (!this.dragging || !this.dropAllowed)
return null;
if (!this.targetTimestamp)
return null;
const event = this.draggedInternalEvent || this.draggedExternalEvent;
if (!event)
return null;
// TODO(chris): calculate length correctly from orig
let length = event.end - event.start;
if (this.targetGridEnds)
length = this.targetGridEnds.find(end => end >= this.targetTimestamp + length) - this.targetTimestamp;
return {
orig: event.orig,
start: this.targetTimestamp,
end: this.targetTimestamp + length
};
}
},
methods: {
onDragstart(evt) {
DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig);
this.draggedInternalEvent = evt.detail.item;
},
onDragend() {
this.draggedInternalEvent = null;
this.dragging = false;
},
onDragenter(evt) {
this.dragging = true;
if (!this.draggedInternalEvent) {
const event = DragAndDrop.getValidTransferData(evt.detail.originalEvent);
if (event) {
this.draggedExternalEvent = {
id: event.id,
type: event.type,
start: event.isostart
? luxon.DateTime.fromISO(event.isostart).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
end: event.isoend
? luxon.DateTime.fromISO(event.isoend).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
orig: event
};
} else {
this.draggedExternalEvent = null;
}
this.dropAllowed = this.dropableEvents(event, this.mode);
} else {
this.dropAllowed = this.dropableEvents(this.draggedInternalEvent, this.mode);
}
},
onDragleave() {
this.dragging = false;
},
onDragchange(evt) {
this.targetTimestamp = evt.detail.timestamp;
this.targetGridEnds = evt.detail.ends || null;
},
onDrop(evt) {
if (!this.dragging || !this.dropAllowed)
return;
this.$emit('drop', evt, this.previewEvent.start, this.previewEvent.end);
this.dropAllowed = false;
this.dragging = false;
}
},
template: `
<div
class="fhc-calendar-base-draganddrop"
@calendar-dragstart="onDragstart"
@calendar-dragend="onDragend"
v-cal-dnd:dropcage
@calendar-dragenter="onDragenter"
@calendar-dragleave="onDragleave"
@calendar-dragchange="onDragchange"
@drop="onDrop"
>
<slot />
</div>
`
}
+429
View File
@@ -0,0 +1,429 @@
import GridLine from './Grid/Line.js';
import GridLineEvent from './Grid/Line/Event.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
export default {
name: "CalendarGrid",
components: {
GridLine,
GridLineEvent
},
directives: {
CalDnd
},
inject: {
originalEvents: "events",
originalBackgrounds: "backgrounds",
dropAllowed: "dropAllowed"
},
provide() {
return {
flipAxis: Vue.computed(() => this.flipAxis),
axisRow: Vue.computed(() => this.axisRow)
};
},
props: {
axisMain: {
type: Array,
required: true,
validator(value) {
return value.every(item => item instanceof luxon.DateTime);
}
},
axisParts: {
type: Array,
required: true,
validator(value) {
return value.every(item =>
item instanceof luxon.Duration
|| Number.isInteger(item)
|| (
(
item.start instanceof luxon.Duration
|| Number.isInteger(item.start)
) && (
item.end instanceof luxon.Duration
|| Number.isInteger(item.end)
)
)
);
}
},
flipAxis: Boolean,
allDayEvents: Boolean,
axisMainCollapsible: Boolean,
snapToGrid: Boolean
},
data() {
return {
dragging: false,
resizeObserver: null,
mutationObserver: null,
userScroll: true
};
},
computed: {
axisRow() {
return this.flipAxis ? 'column' : 'row';
},
axisCol() {
return this.flipAxis ? 'row' : 'column';
},
axisPartsWithBreaks() {
return this.axisParts.reduce((res, tu, index) => {
const start = tu.start || tu;
const end = tu.end;
if (res.length) {
const lastTuEnd = res.pop();
if (Array.isArray(lastTuEnd)) {
res.push({
start: lastTuEnd[0],
end: start,
index: lastTuEnd[1]
});
} else if (lastTuEnd != start) {
// add pause
res.push({
start: lastTuEnd,
end: start
});
}
}
if (!end) {
res.push([start, index]);
} else {
res.push({
start,
end,
index
});
res.push(end);
}
return res;
}, []).slice(0, -1);
},
axisPartsSave() {
if (!this.axisParts[this.axisParts.length - 1].end)
return this.axisParts.slice(0, -1);
return this.axisParts;
},
start() {
return this.axisPartsWithBreaks[0].start;
},
end() {
return this.axisPartsWithBreaks[this.axisPartsWithBreaks.length - 1].end;
},
ends() {
const ends = [];
const partsEnds = this.axisPartsWithBreaks
.filter(p => p.index !== undefined)
.map(p => p.end);
for (var date of this.axisMain)
for (var part of partsEnds)
ends.push(date.plus(part));
return ends;
},
axisMainBorders() {
return this.axisMain.reduce(
(res, curr) => res.concat([curr.plus(this.start), curr.plus(this.end)]),
[]
);
},
eventsAllDay() {
if (!this.allDayEvents)
return [];
return this.mapIntoMainAxis(this.originalEvents.filter(event => event.orig.allDayEvent));
},
eventsNormal() {
if (!this.allDayEvents)
return this.events;
return this.mapIntoMainAxis(this.originalEvents.filter(event => !event.orig.allDayEvent));
},
events() {
return this.mapIntoMainAxis(this.originalEvents);
},
backgrounds() {
return this.mapIntoMainAxis(this.originalBackgrounds);
},
hasValidEvents() {
return this.events.find(e => e.length);
},
styleGridCols() {
let cols = 'repeat(' + this.axisMain.length + ', 1fr)';
if (this.axisMainCollapsible) {
if (this.hasValidEvents)
cols = this.events
.map(e => e.length
? '1fr'
: 'var(--fhc-calendar-axis-collapsible, .5fr)')
.join(' ');
}
return cols;
},
styleGridRows() {
const gridlines = {};
this.axisPartsWithBreaks.forEach(part => {
let ts = part.start.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('ps_' + part.index);
ts = part.end.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('pe_' + part.index);
});
this.eventsNormal.forEach((events, mainIndex) => {
let day = this.axisMain[mainIndex];
events.forEach(event => {
if (!event.startsHere && !event.endsHere)
return;
if (event.startsHere) {
let ts = event.start.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
if (event.endsHere) {
let ts = event.end.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
});
});
return Object.keys(gridlines).sort((a,b) => parseInt(a)-parseInt(b)).map((start, i, keys) => {
let end = keys[i + 1];
if (!end) {
gridlines[start].push('end');
return '[' + gridlines[start].join(' ') + ']';
}
return '[' + gridlines[start].join(' ') + '] ' + (end - start) + 'fr';
}).join(' ');
}
},
methods: {
mapIntoMainAxis(target) {
const result = Array.from({length: this.axisMain.length}, () => Array());
target.forEach(event => {
const start = event.start || this.axisMainBorders[0].plus(-1);
const end = event.end || this.axisMainBorders[this.axisMainBorders.length - 1].plus(1);
for (var i = 0; i < this.axisMain.length; i++) {
if (start < this.axisMainBorders[i * 2 + 1] && end > this.axisMainBorders[i * 2]) {
const startsHere = start >= this.axisMainBorders[i * 2];
const endsHere = end <= this.axisMainBorders[i * 2 + 1];
result[i].push({
...event,
startsHere,
endsHere
});
}
}
});
return result;
},
/* DRAG AND DROP */
getPageTop(el) {
let pageTop = el.offsetTop;
if (el.offsetParent)
pageTop += this.getPageTop(el.offsetParent);
return pageTop;
},
getPageLeft(el) {
let pageLeft = el.offsetLeft;
if (el.offsetParent)
pageLeft += this.getPageLeft(el.offsetParent);
return pageLeft;
},
getTimestampFromMouse(evt, dayTimestamp) {
let mouse, mouseFrac;
if (this.flipAxis) {
mouse = evt.pageX - this.getPageLeft(this.$refs.body) + this.$refs.main.scrollLeft;
mouseFrac = mouse / this.$refs.body.offsetWidth;
} else {
mouse = evt.pageY - this.getPageTop(this.$refs.body) + this.$refs.main.scrollTop;
mouseFrac = mouse / this.$refs.body.offsetHeight;
}
return dayTimestamp + this.start + Math.floor((this.end - this.start) * mouseFrac);
},
/* SCROLLING */
enableAutoScroll() {
if (!this.resizeObserver)
this.resizeObserver = new ResizeObserver(this.scrollToEarliestEvent);
this.resizeObserver.observe(this.$refs.body);
if (!this.mutationObserver)
this.mutationObserver = new MutationObserver(mutations => {
if (mutations.some(m => m.addedNodes.length && [].some.call(m.addedNodes, el => el.matches && el.matches('.fhc-calendar-base-grid-line-event'))))
this.scrollToEarliestEvent();
});
this.mutationObserver.observe(this.$refs.body, {
subtree: true,
childList: true
});
this.scrollToEarliestEvent();
},
disableAutoScroll() {
if (this.resizeObserver)
this.resizeObserver.disconnect();
this.resizeObserver = null;
if (this.mutationObserver)
this.mutationObserver.disconnect();
this.mutationObserver = null;
},
scrollToEarliestEvent() {
const eventElements = this.$refs.scroller.querySelectorAll('.fhc-calendar-base-grid-line-event');
const earliestEventOffset = eventElements.values()
.reduce((res, el) => {
const top = el.offsetTop;
if (!res[1] || top < res[0])
return [top, el];
return res;
}, [0, null]);
this.userScroll = false;
if (earliestEventOffset[1]) {
earliestEventOffset[1].scrollIntoView({ behavior: "smooth" });
} else {
this.$refs.scroller.scrollTo(0, 0);
}
}
},
beforeUnmount() {
this.disableAutoScroll();
},
template: /* html */`
<div
class="fhc-calendar-base-grid"
style="display:grid;width:100%;height:100%"
:style="'grid-template-' + axisRow + 's:auto' + (allDayEvents ? ' auto ' : ' ') + '1fr;grid-template-' + axisCol + 's:auto ' + styleGridCols"
>
<div
class="grid-header"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(date, index) in axisMain"
:key="index"
class="main-header"
:class="{'collapsed-header': axisMainCollapsible && hasValidEvents && !events[index].length}"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<slot name="main-header" v-bind="{ index, date }" />
</div>
</div>
<div
v-if="allDayEvents"
class="grid-allday"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(events, index) in eventsAllDay"
:key="index"
class="all-day-events"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<grid-line-event
v-for="(event, i) in events"
:key="i"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</grid-line-event>
</div>
</div>
<div
ref="scroller"
@scrollend="userScroll ? disableAutoScroll() : userScroll = true"
style="display:grid;overflow:auto"
:style="'grid-' + axisCol + ':1/-1;grid-template-' + axisCol + 's:subgrid'"
>
<div
ref="main"
class="grid-main"
style="position:relative;grid-column:1/-1;grid-row:1/-1;display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-template-' + axisRow + 's:' + styleGridRows"
>
<div
v-for="(part, index) in axisPartsSave"
:key="index"
class="part-header"
:style="'grid-' + axisCol + ':1;grid-' + axisRow + ': ps_' + index + '/pe_' + index"
>
<slot name="part-header" v-bind="{ index, part }" />
</div>
<div
ref="body"
class="grid-body"
style="display:grid;grid-template-rows:subgrid;grid-template-columns:subgrid"
:style="'grid-' + axisCol + ':2/-1;grid-' + axisRow + ':1/-1'"
v-cal-dnd:dropcage
@calendar-dragenter="dragging = true"
@calendar-dragleave="dragging = false"
@dragover="dropAllowed ? $event.preventDefault() : null"
>
<template
v-for="(date, index) in axisMain"
:key="index"
>
<div
v-for="(part, i) in axisPartsSave"
:key="i"
class="part-body"
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone.once="{date: date.plus(part.start || part), ends: ends.slice(ends.findIndex(end => end > date))}"
></div>
</div>
<grid-line
:start="date.plus(start)"
:end="date.plus(end)"
:date="date"
:events="eventsNormal[index]"
:backgrounds="backgrounds[index]"
style="position:relative"
:style="'grid-' + axisRow + ':1/-1;grid-' + axisCol + ':' + (1+index)"
>
<template #event="slot">
<slot name="event" v-bind="slot" />
</template>
<template #dropzone>
<div
v-if="!snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone="evt => getTimestampFromMouse(evt, date)"
></div>
</template>
</grid-line>
</template>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,85 @@
import LineEvent from './Line/Event.js';
import LineBackground from './Line/Background.js';
/**
* TODO(chris):
* Event overflow for Month mode (more-button)
*/
export default {
name: "GridLine",
components: {
LineEvent,
LineBackground
},
inject: {
axisRow: "axisRow"
},
props: {
date: {
type: luxon.DateTime,
required: true
},
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
}
},
computed: {
eventsWithRowInfo() {
const events = [];
this.events.forEach(event => {
const rows = [1, -1];
if (event.startsHere) {
rows[0] = 't_' + event.start.diff(this.date).toMillis();
}
if (event.endsHere) {
rows[1] = 't_' + event.end.diff(this.date).toMillis();
}
events.push({
...event,
rows
});
});
return events;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line"
style="position:relative;display:grid;grid-auto-flow:dense"
:style="'grid-template-' + axisRow + 's:subgrid'"
>
<line-background
v-for="bg in backgrounds"
:start="start"
:end="end"
:background="bg"
></line-background>
<line-event
v-for="(event, i) in eventsWithRowInfo"
:key="i"
:style="'grid-' + axisRow + ': ' + event.rows.join('/')"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</line-event>
<slot name="dropzone" />
</div>
`
}
@@ -0,0 +1,72 @@
export default {
name: "GridLineBackground",
inject: {
flipAxis: "flipAxis"
},
props: {
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
background: {
type: Object,
required: true,
validator(value) {
if (!value.start && !value.end)
return false;
if (value.start && !(value.start instanceof luxon.DateTime))
return false;
if (value.end && !(value.end instanceof luxon.DateTime))
return false;
return true;
}
}
},
computed: {
styles() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.style;
const perc = (this.end.ts - this.start.ts) / 100;
let border = {};
if (this.background.startsHere)
border[this.flipAxis ? 'left' : 'top'] = (this.background.start.diff(this.start)) / perc + '%';
if (this.background.endsHere)
border[this.flipAxis ? 'right' : 'bottom'] = (this.end.diff(this.background.end)) / perc + '%';
if (!this.background.style)
return border;
return [this.background.style, border];
},
classes() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.class;
const result = [];
if (this.background.class)
result.push(this.background.class);
if (this.background.startsHere)
result.push('bg-begin');
if (this.background.endsHere)
result.push('bg-end');
return result;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-background"
:class="classes"
style="position:absolute;inset:0;z-index:0"
:style="styles"
:title="background.title"
>
<span v-if="background.label">{{ background.label }}</span>
</div>
`
}
@@ -0,0 +1,57 @@
import CalDnd from '../../../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../../../directives/Calendar/Click.js';
export default {
name: "GridLineEvent",
directives: {
CalDnd,
CalClick
},
inject: {
draggableEvents: "draggableEvents",
mode: "mode"
},
props: {
event: {
type: Object,
required: true,
validator(value) {
return (value.start && value.end && value.orig);
}
}
},
computed: {
isHeaderOrFooter() {
return ['header', 'footer'].includes(this.event.orig);
},
draggable() {
return !this.isHeaderOrFooter && this.draggableEvents(this.event.orig, this.mode);
},
classes() {
const classes = [];
if (this.isHeaderOrFooter) {
classes.push('event-' + this.event.orig);
} else {
if (this.event.startsHere)
classes.push('event-begin');
if (this.event.endsHere)
classes.push('event-end');
}
return classes
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-event event"
:class="classes"
style="z-index: 1"
:draggable="draggable"
v-cal-dnd:draggable="event"
v-cal-click:event="isHeaderOrFooter ? event : event.orig"
>
<slot :event="isHeaderOrFooter ? event : event.orig">
{{ event.orig }}
</slot>
</div>
`
}
@@ -0,0 +1,123 @@
/**
* TODO(chris): use click-directive
*/
import DatePicker from './Header/Datepicker.js';
export default {
name: "CalendarHeader",
components: {
DatePicker
},
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
modeOptions: {
type: Object,
default: {}
},
btnMonth: Boolean,
btnWeek: Boolean,
btnDay: Boolean,
btnList: Boolean
},
emits: [
"next",
"prev",
"click:mode",
"update:date",
"update:mode"
],
data() {
return {
open: false
};
},
methods: {
clickMode(evt, mode) {
this.$emit('click:mode', evt);
if (!evt.defaultPrevented)
this.$emit('update:mode', mode);
}
},
template: /* html */`
<div class="fhc-calendar-base-header">
<div class="row">
<div class="col">
<slot />
</div>
<div class="col-auto d-flex justify-content-center">
<div class="btn-group" role="group">
<button
class="btn btn-outline-secondary border-0"
@click="$emit('prev')"
:disabled="open"
>
<i class="fa fa-chevron-left"></i>
</button>
<date-picker
:mode="mode"
:date="date"
@update:date="$emit('update:date', $event)"
@open="open = true"
@closed="open = false"
:list-length="modeOptions.length"
/>
<button
class="btn btn-outline-secondary border-0"
@click="$emit('next')"
:disabled="open"
>
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
<div class="col">
<div class="d-flex gap-1 justify-content-end" role="group">
<button
v-if="btnMonth"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'month'}"
@click="clickMode($event, 'month')"
>
<i class="fa fa-calendar-days"></i>
</button>
<button
v-if="btnWeek"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'week'}"
@click="clickMode($event, 'week')"
>
<i class="fa fa-calendar-week"></i>
</button>
<button
v-if="btnDay"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'day'}"
@click="clickMode($event, 'day')"
>
<i class="fa fa-calendar-day"></i>
</button>
<button
v-if="btnList"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'list'}"
@click="clickMode($event, 'list')"
>
<i class="fa fa-table-list"></i>
</button>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,116 @@
// TODO(chris): translate aria-labels
export default {
name: "CalendarHeaderDatepicker",
components: {
VueDatePicker
},
inject: [
"locale",
"timezone"
],
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
listLength: {
type: Number,
default: 7
}
},
emits: [
"update:date"
],
computed: {
convertedDate() {
// convert to target TZ then strip TZ Information
// so the datepicker can work with local times
return this.date.setZone(this.timezone).setZone('local', { keepLocalTime: true });
},
current() {
switch (this.mode) {
case "month":
return {month: this.convertedDate.month-1, year: this.convertedDate.year};
case "list":
return [this.convertedDate.startOf('day').ts, this.convertedDate.startOf('day').plus({ days: this.listLength }).ts - 1];
case "week":
return [this.convertedDate.startOf('week', { useLocaleWeeks: true }).ts, this.convertedDate.endOf('week', { useLocaleWeeks: true }).ts];
case "day":
return this.convertedDate;
default:
return null;
}
},
title() {
switch (this.mode) {
case "month":
return this.date.toLocaleString({ month: 'long', year: 'numeric' });
case "week":
var year = this.date.localWeekYear;
var week = this.date.toFormat('nn');
return this.$p.t('calendar/year_kw', { year, week });
case "list":
return this.date.toLocaleString(luxon.DateTime.DATE_FULL) + '-' + this.date.plus({ days: this.listLength - 1 }).toLocaleString(luxon.DateTime.DATE_FULL);
case "day":
return this.date.toLocaleString(luxon.DateTime.DATE_FULL);
default:
return 'View not Supported';
}
},
weekStart() {
return luxon.Info.getStartOfWeek(this.date)%7;
}
},
methods: {
update(value) {
let date;
switch (this.mode) {
case "month":
value.month++;
date = luxon.DateTime.fromObject(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "list":
case "week":
date = luxon.DateTime.fromJSDate(value[0]).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "day":
date = luxon.DateTime.fromJSDate(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
default:
return; // Don't update if the value is invalid!
}
this.$emit("update:date", date);
},
weekNumbers(date) {
return luxon.DateTime.fromJSDate(date, { locale: this.locale }).localWeekNumber;
}
},
template: /* html */`
<vue-date-picker
:model-value="current"
@update:model-value="update"
:format="() => title"
:month-picker="mode == 'month'"
:week-picker="mode == 'week'"
:range="mode == 'list' ? { autoRange: listLength - 1 } : false"
:text-input="mode == 'day'"
:week-start="weekStart"
:week-numbers="{ type: weekNumbers }"
:clearable="false"
:enable-time-picker="false"
:config="{ keepActionRow: mode != 'month' }"
:action-row="{ showSelect: false, showCancel: false, showNow: mode != 'month', showPreview: false }"
auto-apply
six-weeks
teleport
:locale="locale"
:now-button-label="$p.t('calendar/today')"
:week-num-name="$p.t('calendar/kw')"
/>
`
}
@@ -0,0 +1,39 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDay",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleFull() {
return this.date.toLocaleString({day: 'numeric', month: 'long', year: 'numeric'});
},
titleLong() {
return this.date.toLocaleString({day: '2-digit', month: '2-digit', year: 'numeric'});
},
titleShort() {
return this.date.toLocaleString({day: 'numeric', month: 'numeric'});
},
titleNarrow() {
return this.date.toLocaleString({day: 'numeric'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-day"
v-cal-click:day="date"
>
<span class="full">{{ titleFull }}</span>
<span class="long">{{ titleLong }}</span>
<span class="short">{{ titleShort }}</span>
<span class="narrow">{{ titleNarrow }}</span>
</div>
`
}
@@ -0,0 +1,35 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDow",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleLong() {
return this.date.toLocaleString({weekday: 'long'});
},
titleShort() {
return this.date.toLocaleString({weekday: 'short'});
},
titleNarrow() {
return this.date.toLocaleString({weekday: 'narrow'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-dow"
v-cal-click:dow="date"
>
<b class="long">{{ titleLong }}</b>
<b class="short">{{ titleShort }}</b>
<b class="narrow">{{ titleNarrow }}</b>
</div>
`
}
@@ -0,0 +1,58 @@
export default {
name: "LabelTime",
props: {
part: {
type: [luxon.Duration, Number, Object],
required: true,
validator(value) {
if (value instanceof Object) {
if (value instanceof luxon.Duration)
return true;
let start_ok = true;
let end_ok = true;
if (value.start) {
start_ok = (
value.start instanceof luxon.Duration
|| Number.isInteger(value.start)
);
}
if (value.end) {
end_ok = (
value.end instanceof luxon.Duration
|| Number.isInteger(value.end)
);
}
return start_ok && end_ok;
}
return true;
}
}
},
computed: {
sanitizedTimestamps() {
return this.part.start || this.part.end ? this.part : { start: this.part };
},
start() {
if (!this.sanitizedTimestamps.start)
return null;
return this.formatTime(this.sanitizedTimestamps.start);
},
end() {
if (!this.sanitizedTimestamps.end)
return null;
return this.formatTime(this.sanitizedTimestamps.end);
}
},
methods: {
formatTime(date) {
return date.toISOTime({ suppressSeconds: true });
}
},
template: `
<div class="fhc-calendar-base-label-time">
<span v-if="start">{{ start }}</span>
<span v-if="end">-</span>
<span v-if="end">{{ end }}</span>
</div>
`
}
@@ -0,0 +1,38 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelWeek",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
weeks() {
const firstDay = this.date.startOf('week', { useLocaleWeeks: true });
const lastDay = this.date.endOf('week', { useLocaleWeeks: true });
const weeks = [
{ number: firstDay.localWeekNumber, year: firstDay.localWeekYear },
{ number: lastDay.localWeekNumber, year: lastDay.localWeekYear }
];
if (weeks[0].number == weeks[1].number)
weeks.pop();
return weeks;
}
},
template: `
<div class="fhc-calendar-base-label-week">
<span
v-for="week in weeks"
v-cal-click:week="week"
>
{{ week.number }}
</span>
</div>
`
}
@@ -0,0 +1,129 @@
export default {
name: 'CalendarSlider',
inject: {
time: {
from: "sliderTime",
default: ".3s"
}
},
emits: [
'slid'
],
data() {
return {
target: 0,
extrasAfter: 0,
extrasBefore: 0,
running: false,
promiseResolve: null
}
},
computed: {
itemsAfter() {
return [...Array(this.extrasAfter)].map((i, k) => 1+k);
},
itemsBefore() {
return [...Array(this.extrasBefore)].map((i, k) => k-this.extrasBefore);
},
styleSlider() {
const style = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
};
if (this.running) {
style.left = (-this.target * 100) + '%';
style.transition = 'left ' + this.time + ' ease-in-out';
}
return style;
},
styleBefore() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
right: '100%',
width: (this.extrasBefore * 100) + '%'
};
},
styleAfter() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
left: '100%',
width: (this.extrasAfter * 100) + '%'
};
}
},
methods: {
prevPage() {
return this.slidePages(-1);
},
nextPage() {
return this.slidePages(1);
},
slidePages(dir) {
return new Promise(resolve => {
this.promiseResolve = resolve;
this.running = true;
const newTarget = this.target + dir;
if (newTarget > 0) {
if (this.extrasAfter < newTarget)
this.extrasAfter = newTarget;
} else if (newTarget < 0) {
if (-this.extrasBefore > newTarget)
this.extrasBefore = -newTarget;
}
this.target = newTarget;
});
},
endSlide() {
if (this.promiseResolve) {
this.promiseResolve(this.target);
this.promiseResolve = null;
}
this.$emit('slid', this.target);
this.running = false;
this.target = 0;
this.extrasAfter = this.extrasBefore = 0;
}
},
template: /* html */`
<div
class="fhc-calendar-base-slider h-100"
style="position:relative;overflow:hidden"
>
<div
:style="styleSlider"
@transitionend="endSlide"
>
<div :style="styleBefore">
<div
v-for="i in itemsBefore"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div :style="styleAfter">
<div
v-for="i in itemsAfter"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div style="height:100%;width:100%">
<slot :offset="0" />
</div>
</div>
</div>
`
}
+101
View File
@@ -0,0 +1,101 @@
import BaseSlider from '../Base/Slider.js';
import DayView from './Day/View.js';
export default {
name: "ModeDay",
components: {
BaseSlider,
DayView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen",
"requestModalClose"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('day');
let last = this.focusDate.endOf('day');
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset });
} else {
last = last.plus({ days: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
if (this.rangeOffset) {
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(days) {
const newFocusDate = this.focusDate.plus({ days });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
viewAttrs(days) {
const day = this.focusDate.plus({ days });
return { ...this.$attrs, day };
}
},
mounted() {
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
template: `
<div
class="fhc-calendar-mode-day flex-grow-1 position-relative"
>
<base-slider ref="slider" v-slot="slot">
<day-view
ref="view"
v-bind="viewAttrs(slot.offset)"
@request-modal-open="$emit('requestModalOpen', $event)"
@request-modal-close="$emit('requestModalClose', $event)"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
</day-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,164 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
export default {
name: "DayView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid",
originalEvents: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
emptyMessage: String,
emptyMessageDetails: String,
compact: Boolean
},
emits: [
"requestModalOpen",
"requestModalClose"
],
data() {
return {
chosenEvent: null,
gridMainRef: null
};
},
computed: {
axisMain() {
return [this.day.startOf('day')];
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
},
events() {
return this.originalEvents
.filter(event => event.start < this.day.plus({ days: 1 }) && event.end > this.day)
.sort((a, b) => a.start.ts - b.start.ts)
.map(evt => evt.orig);
},
currentEvent() {
if (this.chosenEvent) {
if (this.events.find(e => e == this.chosenEvent))
return this.chosenEvent;
}
let first = null;
if (this.events)
first = this.events.find(Boolean); // undefined => none found
if (first && first.type == 'loading')
return null; // null => loading
return first;
},
isToday() {
return this.day.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
watch: {
compact() {
if (this.compact) {
if (this.chosenEvent) {
this.$emit('requestModalOpen', {
event: this.chosenEvent,
closeFn: () => { this.chosenEvent = null; }
});
}
} else {
this.$emit('requestModalClose');
}
}
},
methods: {
handleClickDefaults(evt) {
if (evt.detail.source == 'event') {
this.chosenEvent = evt.detail.value;
if (this.compact) {
this.$emit('requestModalOpen', {
event: this.chosenEvent,
closeFn: () => { this.chosenEvent = null; }
});
}
}
}
},
mounted() {
this.gridMainRef = this.$refs.grid.$refs.main;
},
template: /* html */`
<div
class="fhc-calendar-mode-day-view d-flex h-100"
@cal-click-default.capture="handleClickDefaults"
>
<calendar-grid
ref="grid"
:axis-main="axisMain"
:axis-parts="axisParts"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<div :class="{ today: isToday }">
<label-dow
@cal-click="evt => evt.detail.source = 'day'"
v-bind="{ date }"
/>
<label-day
v-bind="{ date }"
/>
</div>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
</div>
<slot v-else v-bind="slot" mode="day" />
</template>
</calendar-grid>
<Teleport :disabled="!gridMainRef" :to="gridMainRef">
<div
v-if="emptyMessage && currentEvent !== null && !currentEvent"
class="fhc-calendar-no-events-overlay"
style="position:absolute;inset:0"
>
{{ emptyMessage }}
</div>
</Teleport>
<div class="event-details" v-if="!compact">
<div
v-if="currentEvent === null"
class="p-4 d-flex w-100 justify-content-center align-items-center"
>
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<h3 v-else-if="!currentEvent">{{ emptyMessageDetails }}</h3>
<slot v-else :event="currentEvent" mode="event" />
</div>
</div>
`
}
+103
View File
@@ -0,0 +1,103 @@
import BaseSlider from '../Base/Slider.js';
import ListView from './List/View.js';
export default {
name: "ModeList",
components: {
BaseSlider,
ListView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
default: 7
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate;
let last = first.plus({ days: this.length });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset });
} else {
last = first.plus({ days: this.rangeOffset + this.length });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(offset) {
const newFocusDate = this.focusDate.plus({ days: offset });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(offset) {
const day = this.focusDate.plus({ days: offset });
return { day, length: this.length };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-list flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<list-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" /></template>
</list-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,98 @@
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import CalDnd from '../../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../../directives/Calendar/Click.js';
// TODO(chris): drag and drop
export default {
name: "ListView",
components: {
LabelDay,
LabelDow
},
directives: {
CalDnd,
CalClick
},
inject: {
draggableEvents: "draggableEvents",
events: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
required: true
}
},
data() {
return {
chosenEvent: null
};
},
computed: {
days() {
return Array.from({ length: this.length }, (e, days) => this.day.plus({ days }));
},
eventsPerDay() {
const eventsPerDay = this.days.map(day => {
return {
day,
events: this.events
.filter(event => event.start < day.plus({ days: 1 }) && event.end > day)
.sort((a, b) => a.start.ts - b.start.ts)
};
});
return eventsPerDay.filter(day => day.events.length);
}
},
methods: {
draggable(event) {
return this.draggableEvents(event.orig, 'list');
},
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-list-view h-100 overflow-auto">
<div v-if="!eventsPerDay.length" class="h-100">
<slot :event="undefined" mode="list" />
</div>
<div v-for="{ day, events } in eventsPerDay">
<div
class="text-center"
:class="{ today: isToday(day) }"
>
<label-dow
:date="day"
class="d-inline"
@cal-click="evt => evt.detail.source = 'day'"
/>
,
<label-day :date="day" class="d-inline" />
</div>
<div v-for="event in events">
<div v-if="event.type == 'loading'" class="placeholder-glow opacity-50">
<span class="placeholder w-100" />
</div>
<div
v-else
class="event"
:draggable="draggable(event)"
v-cal-dnd:draggable="event"
v-cal-click:event="event.orig"
>
<slot :event="event.orig" mode="list" />
</div>
</div>
</div>
</div>
`
}
+126
View File
@@ -0,0 +1,126 @@
import BaseSlider from '../Base/Slider.js';
import MonthView from './Month/View.js';
export default {
name: "ModeMonth",
components: {
BaseSlider,
MonthView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
let last = first.plus({ days: 41 }).endOf('day'); // NOTE(chris): 6 weeks minus 1 day
if (this.rangeOffset != 0) {
const nextFocusDate = this.focusDate.plus({ months: this.rangeOffset});
const nextRangeStart = nextFocusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
if (this.rangeOffset < 0) {
first = nextRangeStart;
} else {
last = nextRangeStart.plus({ days: 41 }).endOf('day');
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('month').diff(this.focusDate.startOf('month'), 'months').months;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(months) {
const newFocusDate = this.focusDate.plus({ months });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(months) {
const day = this.focusDate.plus({ months });
return { day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'week':
// default: Move to week if not in month
let dayInWeek = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.currentDate.zoneName,
locale: this.currentDate.locale
});
if (!this.focusDate.hasSame(dayInWeek.startOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.startOf('week', { useLocaleWeeks: true }));
} else if (!this.focusDate.hasSame(dayInWeek.endOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.endOf('week', { useLocaleWeeks: true }));
}
break;
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-month flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<month-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="month" /></template>
</month-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,95 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelWeek from '../../Base/Label/Week.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelDay from '../../Base/Label/Day.js';
export default {
name: "MonthView",
components: {
CalendarGrid,
LabelWeek,
LabelDow,
LabelDay
},
provide() {
return {
// NOTE(chris): snap events to day
events: Vue.computed(() => {
//const events = [];
const events = this.events.map(event => {
const start = event.start.startOf('day');
const end = event.end.plus({ days: 1 }).startOf('day');
return {
...event,
start,
end
};
});
for (var w = 5; w > -1; w--) {
for (var d = 6; d > -1; d--) {
const startdate = this.axisMain[w].plus(this.axisParts[d]);
events.unshift({
start: startdate,
end: startdate.plus({ days: 1 }),
orig: 'header'
});
}
}
return events;
})
};
},
inject: {
events: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
}
},
computed: {
axisMain() {
const start = this.day.startOf('month').startOf('week', { useLocaleWeeks: true });
return Array.from({ length: 6 }, (e, i) => start.plus({ weeks: i }));
},
axisParts() {
return Array.from({ length: 8 }, (e, i) => luxon.Duration.fromObject({ days: i }));
}
},
methods: {
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-month-view h-100">
<calendar-grid
flip-axis
:axis-main="axisMain"
:axis-parts="axisParts"
snap-to-grid
>
<template #main-header="{ date }">
<label-week v-bind="{ date }" />
</template>
<template #part-header="{ part }">
<label-dow :date="axisMain[0].plus(part)" class="text-center" />
</template>
<template #event="slot">
<label-day
v-if="slot.event.orig == 'header'"
:date="slot.event.start"
:class="{ disabled: !day.hasSame(slot.event.start, 'month'), today: isToday(slot.event.start) }"
/>
<div v-else-if="slot.event.type == 'loading'" class="placeholder-glow opacity-50">
<span class="placeholder w-100 fs-1" />
</div>
<slot v-else v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+113
View File
@@ -0,0 +1,113 @@
import BaseSlider from '../Base/Slider.js';
import WeekView from './Week/View.js';
export default {
name: "ModeWeek",
components: {
BaseSlider,
WeekView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('week', { useLocaleWeeks: true });
let last = this.focusDate.endOf('week', { useLocaleWeeks: true });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ weeks: this.rangeOffset });
} else {
last = last.plus({ weeks: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('week', { useLocaleWeeks: true }).diff(this.focusDate.startOf('week', { useLocaleWeeks: true }), 'weeks').weeks;
if (this.rangeOffset) {
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(weeks) {
const newFocusDate = this.focusDate.plus({ weeks });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
viewAttrs(weeks) {
const day = this.focusDate.plus({ weeks });
return { ...this.$attrs, day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
template: `
<div
class="fhc-calendar-mode-week flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<week-view ref="view" v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="week" /></template>
</week-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,85 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
export default {
name: "WeekView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
collapseEmptyDays: Boolean
},
computed: {
start() {
return this.day.startOf('week', { useLocaleWeeks: true });
},
axisMain() {
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
}
},
methods: {
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-week-view h-100">
<calendar-grid
ref="grid"
:axis-main="axisMain"
:axis-parts="axisParts"
:axis-main-collapsible="collapseEmptyDays"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<div :class="{ today: isToday(date) }">
<label-dow
v-bind="{ date }"
@cal-click="evt => evt.detail.source = 'day'"
/>
<label-day
v-bind="{ date }"
/>
</div>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
</div>
<slot v-else v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+199 -306
View File
@@ -1,353 +1,246 @@
import FhcCalendar from "../../Calendar/Calendar.js";
import CalendarDate from "../../../composables/CalendarDate.js";
import LvModal from "../Mylv/LvModal.js";
import LvMenu from "../Mylv/LvMenu.js"
import FhcCalendar from "../../Calendar/Base.js";
import ApiLvPlan from '../../../api/factory/lvPlan.js';
import ApiAuthinfo from '../../../api/factory/authinfo.js';
import { useEventLoader } from '../../../composables/EventLoader.js';
import ModeDay from '../../Calendar/Mode/Day.js';
import ModeWeek from '../../Calendar/Mode/Week.js';
import ModeMonth from '../../Calendar/Mode/Month.js';
export const DEFAULT_MODE_LVPLAN = 'Week'
const LvPlan = {
export default {
name: 'LvPlan',
components: {
FhcCalendar
},
inject: [
"renderers"
],
props: {
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
data() {
const now = luxon.DateTime.now().setZone(this.viewData.timezone);
return {
events: null,
modes: {
day: Vue.markRaw(ModeDay),
week: Vue.markRaw(ModeWeek),
month: Vue.markRaw(ModeMonth)
},
modeOptions: {
day: {
emptyMessage: Vue.computed(() => this.$p.t('lehre/noLvFound')),
emptyMessageDetails: Vue.computed(() => this.$p.t('lehre/noLvFound')),
compact: false
},
week: {
collapseEmptyDays: false
}
},
currentDay: this.propsViewData?.focus_date,
calendarMode: this.propsViewData?.mode ?? DEFAULT_MODE_LVPLAN,
calendarDate: new CalendarDate(new Date()),
eventCalendarDate: new CalendarDate(new Date()),
currentlySelectedEvent: null,
currentDay: this.propsViewData?.focus_date ? new Date(this.propsViewData.focus_date) : new Date(),
lv: null,
minimized: false,
studiensemester_kurzbz: null,
studiensemester_start: null,
studiensemester_ende: null,
uid: null,
isModalContentResolved: false,
isModalTitleResolved: false,
isShowModal: false,
}
},
props: {
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object,
rowMinHeight: {
type: String,
default: '100px'
},
eventMaxHeight: {
type: String,
default: '125px'
}
},
provide() {
return {
rowMinHeight: this.rowMinHeight,
eventMaxHeight: this.eventMaxHeight
}
},
inject:["renderers"],
watch: {
/* events:{
handler: function(newValue){
if(newValue == null)
setTimeout(()=>{
if(this.events == null){
this.loadEvents();
}
},500);
},
immediate: true,
}, */
modalLoaded:{
handler: function (newValue) {
if (this.isShowModal && newValue.isModalContentResolved && newValue.isModalTitleResolved) {
this.$nextTick(() => {
if(this.$refs.lvmodal) this.$refs.lvmodal.show();
this.isShowModal = false;
});
}
},
immediate: true
},
weekFirstDay: {
handler: async function (newValue) {
let data = await this.fetchStudiensemesterDetails(newValue);
let { studiensemester_kurzbz, start, ende } = data.data;
this.studiensemester_kurzbz = studiensemester_kurzbz;
this.studiensemester_start = start;
this.studiensemester_ende = ende;
},
immediate: true,
},
// forward/backward on history entries happening in lvplan
'propsViewData.lv_id'(newVal) {
// relevant if lv_id can be changed from within this component
},
'propsViewData.mode'(newVal) {
if(this.$refs.calendar) this.$refs.calendar.setMode(newVal)
},
'propsViewData.focus_date'(newVal) {
this.currentDate = new Date(newVal)
}
},
components: {
FhcCalendar, LvModal, LvMenu
uid: null
};
},
computed:{
modalLoaded: function(){
return { isModalContentResolved: this.isModalContentResolved, isModalTitleResolved:this.isModalTitleResolved};
},
downloadLinks: function(){
if(!this.studiensemester_start || !this.studiensemester_ende || !this.uid )return;
let start = new Date(this.studiensemester_start);
start = Math.floor(start.getTime()/1000);
let ende = new Date(this.studiensemester_ende);
ende = Math.floor(ende.getTime() / 1000);
backgrounds() {
let now = luxon.DateTime.now().setZone(this.viewData.timezone);
if (this.calendarMode == 'Month')
return [
{
class: 'background-past',
end: now.startOf('day')
}
];
let download_link =
(format, version = "", target = "") =>
`${FHC_JS_DATA_STORAGE_OBJECT.app_root}cis/private/lvplan/stpl_kalender.php?type=student&pers_uid=
${this.uid}&begin=${start}&ende=${ende}&format=${format}
${version ? '&version=' + version : ''}${target ? '&target=' + target : ''}`;
return [
{ title: "excel", icon: 'fa-solid fa-file-excel', link: download_link('excel') },
{ title: "csv", icon: 'fa-solid fa-file-csv', link: download_link('csv') },
{ title: "ical1", icon: 'fa-regular fa-calendar', link: download_link('ical', '1', 'ical') },
{ title: "ical2", icon: 'fa-regular fa-calendar', link: download_link('ical', '2', 'ical') }
{
class: 'background-past',
end: now,
label: now.startOf('minute').toISOTime({ suppressSeconds: true, includeOffset: false })
}
];
},
weekFirstDay: function () {
return this.calendarDateToString(this.calendarDate.cdFirstDayOfWeek);
},
weekLastDay: function () {
return this.calendarDateToString(this.calendarDate.cdLastDayOfWeek);
},
monthFirstDay: function () {
return this.calendarDateToString(this.eventCalendarDate.cdFirstDayOfCalendarMonth);
},
monthLastDay: function () {
return this.calendarDateToString(this.eventCalendarDate.cdLastDayOfCalendarMonth);
},
},
methods:{
modalTitleResolved: function () {
this.isModalTitleResolved = true;
downloadLinks() {
if (!this.studiensemester_start || !this.studiensemester_ende || !this.uid)
return false;
},
modalContentResolved: function () {
this.isModalContentResolved = true;
},
// component renderers fetches from different addons
modalTitleComponent(type){
return this.renderers[type]?.modalTitle;
},
modalContentComponent(type) {
return this.renderers[type]?.modalContent;
},
calendarEventComponent(type){
return this.renderers[type]?.calendarEvent;
},
const opts = { zone: this.viewData.timezone };
const start = luxon.DateTime
.fromISO(this.studiensemester_start, opts)
.toUnixInteger();
const ende = luxon.DateTime
.fromISO(this.studiensemester_ende, opts)
.toUnixInteger();
fetchStudiensemesterDetails: async function (date) {
return this.$api.call(ApiLvPlan.studiensemesterDateInterval(date));
const download_link = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/lvplan/stpl_kalender.php'
+ '?type=student'
+ '&pers_uid=' + this.uid
+ '&begin=' + start
+ '&ende=' + ende;
return [
{ title: "excel", icon: 'fa-solid fa-file-excel', link: download_link + '&format=excel' },
{ title: "csv", icon: 'fa-solid fa-file-csv', link: download_link + '&format=csv' },
{ title: "ical1", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=1&target=ical' },
{ title: "ical2", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=2&target=ical' }
];
}
},
methods: {
eventStyle(event) {
if (!event.farbe)
return undefined;
return '--event-bg:#' + event.farbe;
},
setSelectedEvent: function (event) {
this.currentlySelectedEvent = event;
},
selectDay: function(day){
const date = day.getFullYear() + "-" +
String(day.getMonth() + 1).padStart(2, "0") + "-" +
String(day.getDate()).padStart(2, "0");
const capitalizedMode = this.calendarMode[0].toUpperCase() + this.calendarMode.slice(1);
handleChangeDate(day) {
const focus_date = day.toISODate();
const mode = this.calendarMode[0].toUpperCase() + this.calendarMode.slice(1);
this.$router.push({
name: "LvPlan",
params: {
mode: capitalizedMode,
focus_date: date,
mode,
focus_date,
lv_id: this.propsViewData?.lv_id || null
}
})
this.currentDay = day;
},
handleChangeMode(mode) {
let m = mode[0].toUpperCase() + mode.slice(1)
if(m === this.calendarMode) return; // TODO(chris): check for date and lv_id too!
const date = this.currentDay.getFullYear() + "-" +
String(this.currentDay.getMonth() + 1).padStart(2, "0") + "-" +
String(this.currentDay.getDate()).padStart(2, "0");
if (m == 'Weeks' || m == 'Years' || m == 'Months') return;
handleChangeMode(newMode) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = (this.currentDay instanceof luxon.DateTime)
? this.currentDay.toISODate()
: this.currentDay;
this.$router.push({
name: "LvPlan",
params: {
mode: m,
focus_date: date,
mode,
focus_date,
lv_id: this.propsViewData?.lv_id ?? null
}
})
this.calendarMode = m
},
showModal: function(event){
this.currentlySelectedEvent = event;
Vue.nextTick(()=>{
if(this.isModalContentResolved && this.isModalTitleResolved){
if(this.$refs.lvmodal) this.$refs.lvmodal.show();
}
else{
this.isShowModal = true;
}
})
},
updateRange: function ({start,end, mounted}) {
let checkDate = (date) => {
return date.m != this.eventCalendarDate.m || date.y != this.eventCalendarDate.y;
}
this.calendarDate = new CalendarDate(end);
// only load month data if the month or year has changed
// or we receive a reload flag from the mounted routine of the components
// or this handler is being called from the mounted lifecycle of a component
if (mounted || (checkDate(new CalendarDate(start)) && checkDate(new CalendarDate(end)))){
// reset the events before querying the new events to activate the loading spinner
this.events = null;
this.eventCalendarDate = new CalendarDate(end);
this.loadEvents();
}
},
calendarDateToString: function (calendarDate) {
return calendarDate instanceof CalendarDate ?
[calendarDate.y, calendarDate.m + 1, calendarDate.d].join('-') :
null;
},
loadEvents: function(){
Promise.allSettled([
this.$api.call(ApiLvPlan.LvPlanEvents(this.monthFirstDay, this.monthLastDay, this.propsViewData.lv_id)),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(this.monthFirstDay, this.monthLastDay))
]).then((result) => {
let promise_events = [];
result.forEach((promise_result) => {
if (promise_result.status === 'fulfilled' && promise_result.value.meta.status === "success") {
if(promise_result.value.meta?.lv) this.lv = promise_result.value.meta.lv
let data = promise_result.value.data;
// adding additional information to the events
if (data && data.forEach) {
data.forEach((el, i) => {
el.id = i;
if (el.type === 'reservierung') {
el.color = '#' + (el.farbe || 'FFFFFF');
} else {
el.color = '#' + (el.farbe || 'CCCCCC');
}
el.start = new Date(el.datum + ' ' + el.beginn);
el.end = new Date(el.datum + ' ' + el.ende);
});
}
promise_events = promise_events.concat(data);
}
})
this.events = promise_events;
});
this.calendarMode = mode;
},
updateRange(rangeInterval) {
this.rangeInterval = rangeInterval;
this.$api
.call(ApiLvPlan.studiensemesterDateInterval(
this.rangeInterval.end.startOf('week').toISODate()
))
.then(res => {
this.studiensemester_kurzbz = res.data.studiensemester_kurzbz;
this.studiensemester_start = res.data.start;
this.studiensemester_ende = res.data.ende;
});
}
},
setup(props) {
const $api = Vue.inject('$api');
const rangeInterval = Vue.ref(null);
const { events, lv } = useEventLoader(rangeInterval, (start, end) => {
return [
$api.call(ApiLvPlan.LvPlanEvents(start.toISODate(), end.toISODate(), props.propsViewData.lv_id)),
$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
];
});
return {
rangeInterval,
events,
lv
};
},
created() {
this.$api
.call(ApiAuthinfo.getAuthUID())
.then(res => res.data)
.then(data => {
this.uid = data.uid;
.then(res => {
this.uid = res.data.uid;
});
// this.loadEvents();
},
beforeUnmount() {
if(this.$refs.lvmodal) this.$refs.lvmodal.hide()
},
template:/*html*/`
<template v-if="renderers">
<h2>
{{$p.t('lehre/stundenplan')}}
<span style="padding-left: 0.4em;" v-show="studiensemester_kurzbz">{{studiensemester_kurzbz}}</span>
<span style="padding-left: 0.5em;" v-show="propsViewData?.lv_id && lv"> {{ $p.user_language.value === 'German' ? lv?.bezeichnung : lv?.bezeichnung_english}}</span>
</h2>
<hr>
<lv-modal v-if="currentlySelectedEvent" :event="currentlySelectedEvent" ref="lvmodal" >
<template #modalTitle>
<Suspense @pending="isModalTitleResolved=false" @resolve="modalTitleResolved">
<component :is="modalTitleComponent(currentlySelectedEvent.type)" v-if="currentlySelectedEvent" :event="currentlySelectedEvent" ></component>
</Suspense>
</template>
<template #modalContent>
<Suspense @pending="isModalContentResolved=false" @resolve="modalContentResolved">
<component :is="modalContentComponent(currentlySelectedEvent.type)" v-if="currentlySelectedEvent" :event="currentlySelectedEvent" ></component>
</Suspense>
</template>
</lv-modal>
<fhc-calendar
ref="calendar"
@selectedEvent="setSelectedEvent"
:initial-date="currentDay"
@change:range="updateRange"
@change:offset="handleOffset"
:events="events"
:initial-mode="propsViewData.mode"
show-weeks
@select:day="selectDay"
@change:mode="handleChangeMode"
v-model:minimized="minimized"
>
<template #calendarDownloads>
<div v-for="{title,icon,link} in downloadLinks">
<a :href="link" :aria-label="title" class="py-1 px-2 m-1 btn btn-outline-secondary card">
<div class="d-flex flex-column">
<i aria-hidden="true" :class="icon"></i>
<span class="small">{{title}}</span>
<div class="fhc-lvplan d-flex flex-column h-100" v-if="renderers">
<h2 @click="modeOptions.week.collapseEmptyDays = !modeOptions.week.collapseEmptyDays">
{{ $p.t('lehre/stundenplan') }}
<span style="padding-left: 0.4em;" v-show="studiensemester_kurzbz">
{{ studiensemester_kurzbz }}
</span>
<span style="padding-left: 0.5em;" v-show="propsViewData?.lv_id && lv">
{{ $p.user_language.value === 'German' ? lv?.bezeichnung : lv?.bezeichnung_english }}
</span>
</h2>
<hr>
<fhc-calendar
ref="calendar"
:date="currentDay"
:modes="modes"
:mode-options="modeOptions"
:mode="propsViewData.mode.toLowerCase()"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
@update:range="updateRange"
:timezone="viewData.timezone"
:locale="$p.user_locale.value"
show-btns
:events="events || []"
:backgrounds="backgrounds"
>
<template v-slot="{ event, mode }">
<div
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:type="mode == 'day' ? 'button' : undefined"
:style="eventStyle(event)"
>
<component
v-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<component
v-else
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
<template #actions>
<div
v-if="downloadLinks"
class="d-flex gap-1 justify-items-start"
>
<div v-for="{ title, icon, link } in downloadLinks">
<a
:href="link"
:aria-label="title"
class="py-1 px-2 m-1 btn btn-outline-secondary card"
>
<div class="d-flex flex-column">
<i aria-hidden="true" :class="icon"></i>
<span class="small">{{ title }}</span>
</div>
</a>
</div>
</a>
</div>
</template>
<template #monthPage="{event,day}">
<div @click="showModal(event)" class="monthPageContainer " >
<component :is="calendarEventComponent(event.type)" :event="event" ></component>
</div>
</template>
<template #weekPage="{event,day}">
<div @click="showModal(event)" type = "button"
class="weekPageContainer position-relative h-100"
:class="{'p-1':event.allDayEvent}"
style = "overflow: auto;" >
<component :is="calendarEventComponent(event.type)" :event="event" ></component>
</div>
</template>
<template #dayPage="{event,day,mobile}">
<div @click="mobile? showModal(event):null" type="button" class="dayPageContainer fhc-entry m-0 h-100 text-center">
<component :is="calendarEventComponent(event.type)" :event="event"></component>
</div>
</template>
<template #pageMobilContent="{lvMenu, event}">
<component :is="modalContentComponent(currentlySelectedEvent.type)" v-if="event" :event="event" :lvMenu="lvMenu" ></component>
</template>
<template #pageMobilContentEmpty >
<h3>{{ $p.t('lehre/noLvFound') }}</h3>
</template>
</fhc-calendar>
</template>`
}
export default LvPlan;
</div>
</template>
</fhc-calendar>
</div>`
};
+1 -1
View File
@@ -55,7 +55,7 @@ export default {
class="menu-entry p-2 w-100 text-wrap border border-1 rounded-3 d-flex flex-column align-items-center justify-content-center text-center text-decoration-none link h-100">
<img :src="menuItem.c4_icon" :alt="menuItem.name" />
<p class="w-100 mt-2 mb-0">{{menuItem.name}}</p>
<a v-for="([text,link],index) in menuItem.c4_linkList" target="_blank" :href="link" class="my-1 w-100 submenu text-decoration-none" :index="index">{{text}}</p>
<a v-for="([text,link],index) in menuItem.c4_linkList" target="_blank" :href="link" class="my-1 w-100 submenu text-decoration-none" :index="index">{{text}}</a>
</a>
<ul v-if="menuItem.c4_moodle_links?.length" class="dropdown-menu p-0" :aria-labelledby="menuItem.name">
<li v-for="item in menuItem.c4_moodle_links"><a class="dropdown-item border-bottom" :href="item.url">{{item.lehrform}}</a></li>
+152 -207
View File
@@ -1,259 +1,204 @@
import FhcCalendar from "../../Calendar/Calendar.js";
import CalendarDate from "../../../composables/CalendarDate.js";
import LvModal from "../../../components/Cis/Mylv/LvModal.js";
import LvInfo from "../../../components/Cis/Mylv/LvInfo.js"
import FhcCalendar from "../../Calendar/Base.js";
import ApiStudenplan from '../../../api/factory/lvPlan.js';
import { useEventLoader } from '../../../composables/EventLoader.js';
import ModeDay from '../../Calendar/Mode/Day.js';
import ModeWeek from '../../Calendar/Mode/Week.js';
import ModeMonth from '../../Calendar/Mode/Month.js';
export const DEFAULT_MODE_RAUMINFO = 'Week'
const RoomInformation = {
export default {
name: "RoomInformation",
props:{
propsViewData: {
type: Object
},
rowMinHeight: {
type: String,
default: '100px'
},
eventMaxHeight: {
type: String,
default: '125px'
}
},
components: {
FhcCalendar,
LvModal,
LvInfo,
FhcCalendar
},
provide() {
return {
rowMinHeight: this.rowMinHeight,
eventMaxHeight: this.eventMaxHeight
}
inject: [
"renderers"
],
props:{
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
data() {
return {
events: null,
calendarMode: DEFAULT_MODE_RAUMINFO,
calendarDate: new CalendarDate(new Date()),
currentlySelectedEvent: null,
currentDay: this.propsViewData?.focus_date ? new Date(this.propsViewData.focus_date) : new Date(),
minimized: false,
}
modes: {
day: Vue.markRaw(ModeDay),
week: Vue.markRaw(ModeWeek),
month: Vue.markRaw(ModeMonth)
},
modeOptions: {
day: {
emptyMessage: Vue.computed(() => this.$p.t('rauminfo/keineRaumReservierung')),
emptyMessageDetails: Vue.computed(() => this.$p.t('rauminfo/keineRaumReservierung')),
compact: false
},
week: {
collapseEmptyDays: false
}
},
currentDay: this.propsViewData?.focus_date,
calendarMode: this.propsViewData?.mode ?? DEFAULT_MODE_RAUMINFO
}
},
computed:{
currentDate: function(){
return new Date(this.calendarWeek.y, this.calendarWeek.m, this.calendarWeek.d);
},
weekFirstDay: function () {
return this.calendarDateToString(this.calendarDate.cdFirstDayOfWeek);
},
weekLastDay: function () {
return this.calendarDateToString(this.calendarDate.cdLastDayOfWeek);
},
monthFirstDay: function () {
return this.calendarDateToString(this.calendarDate.cdFirstDayOfCalendarMonth);
},
monthLastDay: function () {
return this.calendarDateToString(this.calendarDate.cdLastDayOfCalendarMonth);
},
},
watch: {
'propsViewData.ort_kurzbz'(newVal) {
// relevant if ort_kurzbz can be changed from within this component
},
'propsViewData.mode'(newVal) {
if(this.$refs.calendar) this.$refs.calendar.setMode(newVal)
},
'propsViewData.focus_date'(newVal) {
this.currentDate = new Date(newVal)
}
computed: {
backgrounds() {
let now = luxon.DateTime.now().setZone(this.viewData.timezone);
if (this.calendarMode == 'Month')
return [
{
class: 'background-past',
end: now.startOf('day')
}
];
return [
{
class: 'background-past',
end: now,
label: now.startOf('minute').toISOTime({ suppressSeconds: true, includeOffset: false })
}
];
}
},
methods:{
setSelectedEvent: function(event){
this.currentlySelectedEvent = event;
methods:{
eventStyle(event) {
if (!event.farbe)
return undefined;
return '--event-bg:#' + event.farbe;
},
getLvID: function () {
this.lv_id = window.location.pathname
},
selectDay: function(day){
const date = day.getFullYear() + "-" +
String(day.getMonth() + 1).padStart(2, "0") + "-" +
String(day.getDate()).padStart(2, "0");
handleChangeDate(day) {
const focus_date = day.toISODate();
const mode = this.calendarMode[0].toUpperCase() + this.calendarMode.slice(1);
this.$router.push({
name: "RoomInformation",
params: {
mode: this.calendarMode,
focus_date: date,
mode,
focus_date,
ort_kurzbz: this.propsViewData.ort_kurzbz
}
})
this.currentDay = day;
},
handleOffset: function(offset) {
this.currentDay = new Date(
this.currentDay.getFullYear() + offset.y,
this.currentDay.getMonth() + offset.m,
this.currentDay.getDate() + offset.d
)
handleChangeMode(newMode) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = (this.currentDay instanceof luxon.DateTime)
? this.currentDay.toISODate()
: this.currentDay;
const date = this.currentDay.getFullYear() + "-" +
String(this.currentDay.getMonth() + 1).padStart(2, "0") + "-" +
String(this.currentDay.getDate()).padStart(2, "0");
this.$router.push({
name: "LvPlan",
params: {
mode: this.calendarMode,
focus_date: date,
lv_id: this.propsViewData?.lv_id || null
}
})
},
handleChangeMode(mode) {
const modeCapitalized = mode.charAt(0).toUpperCase() + mode.slice(1)
const date = this.currentDay.getFullYear() + "-" +
String(this.currentDay.getMonth() + 1).padStart(2, "0") + "-" +
String(this.currentDay.getDate()).padStart(2, "0");
this.$router.push({
name: "RoomInformation",
params: {
mode: modeCapitalized,
focus_date: date,
mode,
focus_date,
ort_kurzbz: this.propsViewData.ort_kurzbz
}
})
this.calendarMode = mode
},
showModal: function (event) {
this.currentlySelectedEvent = event;
Vue.nextTick(() => {
this.$refs.lvmodal.show();
});
},
updateRange: function ({ start, end }) {
let checkDate = (date) => {
return date.m != this.calendarDate.m || date.y != this.calendarDate.y;
}
// only load month data if the month or year has changed
if (checkDate(new CalendarDate(start)) && checkDate(new CalendarDate(end))) {
// reset the events before querying the new events to activate the loading spinner
this.events = null;
this.calendarDate = new CalendarDate(end);
Vue.nextTick(() => {
this.loadEvents();
});
}
},
calendarDateToString: function (calendarDate) {
return calendarDate instanceof CalendarDate ?
[calendarDate.y, calendarDate.m + 1, calendarDate.d].join('-') :
null;
},
loadEvents: function(){
// bundles the room_events and the reservierungen together into the this.events array
Promise.allSettled([
this.$api.call(ApiStudenplan.getRoomInfo(this.propsViewData.ort_kurzbz, this.monthFirstDay, this.monthLastDay)),
this.$api.call(ApiStudenplan.getOrtReservierungen(this.propsViewData.ort_kurzbz, this.monthFirstDay, this.monthLastDay))
]).then((result) => {
let promise_events = [];
result.forEach((promise_result) => {
if(promise_result.status === 'fulfilled' && promise_result.value.meta.status === "success"){
let data = promise_result.value.data;
// adding additional information to the events
if (data && data.forEach) {
data.forEach((el, i) => {
el.id = i;
if (el.type === 'reservierung') {
el.color = '#' + (el.farbe || 'FFFFFF');
} else {
el.color = '#' + (el.farbe || 'CCCCCC');
}
el.start = new Date(el.datum + ' ' + el.beginn);
el.end = new Date(el.datum + ' ' + el.ende);
});
}
promise_events = promise_events.concat(data);
}
})
this.events = promise_events;
})
},
},
created() {
this.loadEvents();
updateRange(rangeInterval) {
this.rangeInterval = rangeInterval;
}
},
template: /*html*/`
setup(props) {
const $api = Vue.inject('$api');
const rangeInterval = Vue.ref(null);
const { events } = useEventLoader(rangeInterval, (start, end) => {
return [
$api.call(ApiStudenplan.getRoomInfo(props.propsViewData.ort_kurzbz, start.toISODate(), end.toISODate())),
$api.call(ApiStudenplan.getOrtReservierungen(props.propsViewData.ort_kurzbz, start.toISODate(), end.toISODate()))
];
});
return {
rangeInterval,
events
};
},
template: /*html*/`
<div class="fhc-roominformation d-flex flex-column h-100">
<h2>{{ $p.t('rauminfo/rauminfo') }} {{ propsViewData.ort_kurzbz }}</h2>
<hr>
<lv-modal v-if="currentlySelectedEvent" :showMenu="false" :event="currentlySelectedEvent" ref="lvmodal" />
<fhc-calendar
ref="calendar"
@selectedEvent="setSelectedEvent"
:initial-date="currentDay"
@change:range="updateRange"
@change:offset="handleOffset"
:events="events"
:initial-mode="propsViewData.mode"
show-weeks
@select:day="selectDay"
@change:mode="handleChangeMode"
v-model:minimized="minimized"
:date="currentDay"
:modes="modes"
:mode-options="modeOptions"
:mode="propsViewData.mode.toLowerCase()"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
@update:range="updateRange"
:timezone="viewData.timezone"
:locale="$p.user_locale.value"
show-btns
:events="events || []"
:backgrounds="backgrounds"
>
<template #monthPage="{event,day}">
<span >
{{event.topic}}
</span>
</template>
<template #weekPage="{event,day}">
<div @click="showModal(event)" type="button" class=" border border-secondary border d-flex flex-column align-items-center justify-content-evenly h-100">
<span>{{event?.topic}}</span>
<span v-for="lektor in event?.lektor">{{lektor.kurzbz}}</span>
<span>{{event?.ort_kurzbz}}</span>
<template v-slot="{ event, mode }">
<component
v-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<div
v-else-if="mode == 'month'"
:class="'event-type-' + event.type"
:style="eventStyle(event)"
class="d-flex flex-column align-items-center justify-content-evenly h-100"
>
<span>{{ event?.topic }}</span>
</div>
</template>
<template #dayPage="{event,day,mobile}">
<div @click="mobile? showModal(event):null" type="button" class="fhc-entry border border-secondary border row h-100 justify-content-center align-items-center text-center">
<div
v-else-if="mode == 'week'"
:class="'event-type-' + event.type"
:style="eventStyle(event)"
class="border border-secondary d-flex flex-column align-items-center justify-content-evenly h-100"
type="button"
>
<span>{{ event?.topic }}</span>
<span v-for="lektor in event?.lektor">{{ lektor.kurzbz }}</span>
<span>{{ event?.ort_kurzbz }}</span>
</div>
<div
v-else-if="mode == 'day'"
:class="'event-type-' + event.type"
:style="eventStyle(event)"
type="button"
class="border border-secondary d-flex align-items-center justify-content-center text-center h-100"
>
<div class="col ">
<p>{{ $p.t('lehre/lehrveranstaltung') }}:</p>
<p class="m-0">{{event?.topic}}</p>
<p class="m-0">
{{ event?.topic }}
</p>
</div>
<div class="col ">
<p>{{ $p.t('lehre/lektor') }}:</p>
<p class="m-0" v-for="lektor in event?.lektor">{{lektor.kurzbz}}</p>
<p class="m-0" v-for="lektor in event?.lektor">
{{ lektor.kurzbz }}
</p>
</div>
<div class="col ">
<p>{{ $p.t('profil/Ort') }}: </p>
<p class="m-0">{{event?.ort_kurzbz}}</p>
<p class="m-0">
{{ event?.ort_kurzbz }}
</p>
</div>
</div>
</template>
<template #pageMobilContent>
<h3 >{{$p.t('lvinfo','lehrveranstaltungsinformationen')}}</h3>
<div class="w-100">
<lv-info :event="currentlySelectedEvent" />
</div>
</template>
<template #pageMobilContentEmpty >
<h3>{{$p.t('rauminfo','keineRaumReservierung')}}</h3>
</template>
</fhc-calendar>
`,
</fhc-calendar>
</div>`
};
export default RoomInformation
@@ -1,20 +1,23 @@
import { numberPadding, formatDate } from "../../../../helpers/DateHelpers.js"
import LvMenu from "../../Mylv/LvMenu.js";
import ApiLvPlan from '../../../../api/factory/lvPlan.js';
import ApiAddons from '../../../../api/factory/addons.js';
export default {
components:{
LvMenu,
},
props:{
event: {
type: Object,
required: true,
},
lvMenu:{
type: Object,
required: false,
default: null,
},
}
},
components:{
LvMenu,
data() {
return {
lvMenu: []
};
},
computed: {
lektorenLinks: function () {
@@ -33,14 +36,14 @@ export default {
},
start_time: function () {
if (!this.event.start) return 'N/A';
if (!this.event.start instanceof Date) {
if (!(this.event.start instanceof Date)) {
return this.event.start;
}
return numberPadding(this.event.start.getHours()) + ":" + numberPadding(this.event.start.getMinutes());
},
end_time: function () {
if (!this.event.end) return 'N/A';
if (!this.event.end instanceof Date) {
if (!(this.event.end instanceof Date)) {
return this.event.end;
}
return numberPadding(this.event.end.getHours()) + ":" + numberPadding(this.event.end.getMinutes());
@@ -54,6 +57,22 @@ export default {
return formatDate(d);
},
},
created() {
if (this.event.type == 'lehreinheit') {
this.$api
.call(ApiLvPlan.getLehreinheitStudiensemester(Array.isArray(this.event.lehreinheit_id) ? this.event.lehreinheit_id[0] : this.event.lehreinheit_id))
.then(res => res.data)
.then(studiensemester_kurzbz => this.$api.call(
ApiAddons.getLvMenu(
this.event.lehrveranstaltung_id,
studiensemester_kurzbz
)
))
.then(res => {
this.lvMenu = res.data;
});
}
},
template: `
<div>
<h5>
@@ -122,6 +141,6 @@ export default {
</tbody>
</table>
<lv-menu :containerStyles="['p-0']" :rowStyles="['m-0']" v-if="lvMenu" :menu="lvMenu" />
<lv-menu :containerStyles="['p-0']" :rowStyles="['m-0']" v-if="lvMenu.length" :menu="lvMenu" />
</div>`,
}
+2 -2
View File
@@ -19,9 +19,8 @@ export default {
viewData: {
type: Object,
required: true,
default: () => ({name: '', uid: ''}),
validator(value) {
return value && value.name && value.uid
return value && value.name && value.uid && value.timezone
}
}
},
@@ -37,6 +36,7 @@ export default {
return {
editMode: Vue.computed(()=>this.editMode),
widgetsSetup: Vue.computed(() => this.widgets),
timezone: Vue.computed(() => this.viewData.timezone)
}
},
computed: {
+4 -4
View File
@@ -209,11 +209,11 @@ export default {
});
},
template: `
<h4 v-if="editModeIsActive" class=" mb-2">
<i v-tooltip="showSectionInformation(name)" class="fa-solid fa-circle-info section-info" ></i>
{{sectionNameTranslation()}}:
</h4>
<div class="dashboard-section position-relative pb-3 border-bottom" ref="container" :style="getSectionStyle">
<h4 v-if="editModeIsActive" class=" mb-2">
<i v-tooltip="showSectionInformation(name)" class="fa-solid fa-circle-info section-info" ></i>
{{sectionNameTranslation()}}:
</h4>
<button v-tooltip="$p.t('dashboard','addLine')" v-if="!additionalRow && editModeIsActive" @click="additionalRow=true" class="btn btn-outline-secondary rounded-circle newGridRow d-flex justify-content-center align-items-center">+</button>
<drop-grid v-model:cols="gridWidth" v-model:additionalRow="additionalRow" :items="items" :itemsSetup="computedWidgetsSetup" :active="editModeIsActive" :resize-limit="checkResizeLimit" :margin-for-extra-row=".01" @draggedItem="draggedItem=$event" @rearrange-items="updatePositions" @gridHeight="gridHeight=$event" >
<template #default="item">
+85 -236
View File
@@ -1,266 +1,115 @@
import Phrasen from '../../mixins/Phrasen.js';
import AbstractWidget from './Abstract.js';
import FhcCalendar from '../Calendar/Calendar.js';
import LvModal from '../Cis/Mylv/LvModal.js';
import ContentModal from '../Cis/Cms/ContentModal.js'
import CalendarDate from '../../composables/CalendarDate.js';
import FhcCalendar from '../Calendar/Base.js';
import ApiLvPlan from '../../api/factory/lvPlan.js';
import ApiOrt from '../../api/factory/ort.js';
import { useEventLoader } from '../../composables/EventLoader.js';
import ModeList from '../Calendar/Mode/List.js';
export default {
name: "LvPlanWidget",
components: {
FhcCalendar
},
mixins: [
Phrasen,
AbstractWidget
],
components: {
FhcCalendar,
LvModal,
ContentModal
},
inject: [
"renderers",
"timezone"
],
data() {
return {
stunden: [],
minimized: true,
events: null,
currentDay: this.getCurrentDate(),
calendarDate: new CalendarDate(new Date()),
roomInfoContentID: null,
ort_kurzbz: null,
selectedEvent: null,
isModalContentResolved: false,
isModalTitleResolved: false,
isShowModal: false,
}
},
inject: ["renderers"],
watch:{
modalLoaded: {
handler: function (newValue) {
if (this.isShowModal && newValue.isModalContentResolved && newValue.isModalTitleResolved) {
this.$nextTick(() => {
if (this.$refs.lvmodal) this.$refs.lvmodal.show();
this.isShowModal = false;
});
now: luxon.DateTime.now().setZone(this.timezone),
modes: {
list: Vue.markRaw(ModeList)
},
modeOptions: {
list: {
length: 7
}
},
immediate: true
},
},
computed: {
modalLoaded: function () {
return { isModalContentResolved: this.isModalContentResolved, isModalTitleResolved: this.isModalTitleResolved };
},
allEventsGrouped() {
// groups all events of the next 7 days together
const currentCalendarDate = new CalendarDate(this.currentDay)
const mapArr = currentCalendarDate.nextSevenDays.map((d) => [new CalendarDate(d), []])
// return map of key => calendar date of next 7 days & values the respective events on that date
return new Map((this.events || []).filter(evt => evt.start >= this.currentDay).reduce((acc, cur) => {
const date = new CalendarDate(new Date(cur.datum))
const arr = acc.find(el => el[0].compare(date))
if(!arr) return acc
arr[1].push(cur)
return acc
}, mapArr));
},
currentEvents() {
return (this.events || []).filter(evt => evt.end < this.dayAfterCurrentDay && evt.start >= this.currentDay);
},
dayAfterCurrentDay() {
let currentDay = new Date(this.currentDay);
currentDay.setDate(currentDay.getDate() + 1);
return currentDay;
},
monthFirstDay: function () {
return this.calendarDateToString(this.calendarDate.cdFirstDayOfCalendarMonth);
},
monthLastDay: function () {
return this.calendarDateToString(this.calendarDate.cdLastDayOfNextCalendarMonth);
},
currentDay: luxon.DateTime.now().setZone(this.timezone).startOf('day')
}
},
methods: {
showModal: function (event) {
this.currentlySelectedEvent = event;
Vue.nextTick(() => {
if (this.isModalContentResolved && this.isModalTitleResolved) {
if (this.$refs.lvmodal) this.$refs.lvmodal.show();
}
else {
this.isShowModal = true;
}
})
},
modalTitleResolved: function () {
this.isModalTitleResolved = true;
},
modalContentResolved: function () {
this.isModalContentResolved = true;
},
modalTitleComponent(type) {
return this.renderers[type]?.modalTitle;
},
modalContentComponent(type) {
return this.renderers[type]?.modalContent;
},
calendarEventComponent(type) {
return this.renderers[type]?.calendarEvent;
},
getEventStyle: function(evt) {
const styles = {'background-color': evt.color};
if(evt.start.getTime() < Date.now()) styles.opacity = 0.5;
eventStyle(event) {
const styles = {};
if (event.farbe)
styles['--event-bg'] = '#' + event.farbe;
else if (event.type == 'reservierung')
styles['--event-bg'] = '#ffffff';
else
styles['--event-bg'] = '#cccccc';
const eventEnd = luxon.DateTime.fromISO(event.isoend, { zone: this.timezone });
if (eventEnd < this.now)
styles['opacity'] = .5;
return styles;
},
getCurrentDate: function() {
const today = new Date()
today.setHours(0,0,0)
return today
},
calendarDateToString: function (calendarDate) {
return calendarDate instanceof CalendarDate ?
[calendarDate.y, calendarDate.m + 1, calendarDate.d].join('-') :
null;
},
showRoomInfoModal: function(ort_kurzbz){
// getting the content_id of the ort_kurzbz
this.$api
.call(ApiOrt.getContentID(ort_kurzbz))
.then(res => {
this.roomInfoContentID = res.data;
this.ort_kurzbz = ort_kurzbz;
// only showing the modal after vue was able to set the reactive data
Vue.nextTick(() => { this.$refs.contentModal.show(); });
})
.catch(err => {
console.err(err);
this.ort_kurzbz = null;
this.roomInfoContentID = null;
});
},
showLvUebersicht: function (event){
this.selectedEvent= event;
Vue.nextTick(()=>{
this.$refs.lvmodal.show();
});
},
selectDay(day) {
this.currentDay = day;
this.minimized = true;
},
updateRange: function (data) {
let tmp_date = new CalendarDate(data.start);
// only load month data if the month or year has changed
if (tmp_date.m != this.calendarDate.m || tmp_date.y != this.calendarDate.y) {
this.calendarDate = tmp_date;
Vue.nextTick(() => {
this.loadEvents();
});
}
},
loadEvents: function () {
Promise.allSettled([
this.$api.call(ApiLvPlan.LvPlanEvents(this.monthFirstDay, this.monthLastDay)),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(this.monthFirstDay, this.monthLastDay))
]).then((result) => {
let promise_events = [];
result.forEach((promise_result) => {
if (promise_result.status === 'fulfilled' && promise_result.value.meta.status === "success") {
let data = promise_result.value.data;
// adding additional information to the events
if (data && data.forEach) {
data.forEach((el, i) => {
el.id = i;
if (el.type === 'reservierung') {
el.color = '#' + (el.farbe || 'FFFFFF');
} else {
el.color = '#' + (el.farbe || 'CCCCCC');
}
el.start = new Date(el.datum + ' ' + el.beginn);
el.end = new Date(el.datum + ' ' + el.ende);
});
}
promise_events = promise_events.concat(data);
}
})
this.events = promise_events;
});
},
setCalendarMaximized() {
this.minimized = false
updateRange(rangeInterval) {
this.rangeInterval = rangeInterval;
}
},
setup() {
const $api = Vue.inject('$api');
const rangeInterval = Vue.ref(null);
const { events } = useEventLoader(rangeInterval, (start, end) => {
return [
$api.call(ApiLvPlan.LvPlanEvents(start.toISODate(), end.toISODate())),
$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
];
});
return {
rangeInterval,
events
};
},
created() {
this.$emit('setConfig', false);
this.loadEvents();
},
template: /*html*/`
<div class="dashboard-widget-lvplan d-flex flex-column h-100">
<lv-modal v-if="selectedEvent" ref="lvmodal" :event="selectedEvent" >
<template #modalTitle>
<Suspense @pending="isModalTitleResolved=false" @resolve="modalTitleResolved">
<component :is="modalTitleComponent(selectedEvent.type)" v-if="selectedEvent" :event="selectedEvent" ></component>
</Suspense>
</template>
<template #modalContent>
<Suspense @pending="isModalContentResolved=false" @resolve="modalContentResolved">
<component :is="modalContentComponent(selectedEvent.type)" v-if="selectedEvent" :event="selectedEvent" ></component>
</Suspense>
</template>
</lv-modal>
<content-modal :content_id="roomInfoContentID" dialogClass="modal-lg" ref="contentModal"/>
<fhc-calendar @change:range="updateRange" :initial-date="currentDay" class="border-0" class-header="p-0" @select:day="selectDay" :widget="true" v-model:minimized="minimized" :events="events" no-week-view :show-weeks="false" >
<template #monthPage="{event,day}">
<div v-if="event.type=='moodle'">
<div class="d-flex small w-100" >
<moodle-svg></moodle-svg>
<span v-contrast class="flex-grow-1 text-center "><strong v-html="event.titel"></strong> - {{event.topic}}</span>
</div>
<fhc-calendar
v-model:date="currentDay"
:modes="modes"
:mode-options="modeOptions"
@update:range="updateRange"
:timezone="timezone"
:locale="$p.user_locale.value"
:events="events"
>
<template v-slot="{ event, mode }">
<div
v-if="!event"
class="h-100 d-flex justify-content-center align-items-center"
>
{{ $p.t('lehre/noLvFound') }}
</div>
<span v-else class="small" >
{{event.topic}}
</span>
</template>
<template #minimizedPage >
<div class="minimizedContainer flex-grow-1" style="overflow-y: auto; overflow-x: hidden">
<div v-if="events === null" class="d-flex h-100 justify-content-center align-items-center">
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<template v-else-if="allEventsGrouped.size" v-for="([key, value], index) in allEventsGrouped" :key="index" style="margin-top: 8px;">
<div class=" card-header d-grid p-0">
<button class="btn fhc-tertiary text-decoration-none" @click="setCalendarMaximized">{{ key.format({dateStyle: "full"}, $p.user_locale.value)}}</button>
</div>
<div role="button" @click="showLvUebersicht(evt)" v-for="evt in value" :key="evt.id" class="mx-1 list-group-item small" :style="getEventStyle(evt)">
<component :is="calendarEventComponent(evt.type)" :event="evt" ></component>
</div>
<div v-if="!value.length" class="list-group-item small text-center">
{{ $p.t('lehre/noLvFound') }}
</div>
</template>
<div v-else class="d-flex h-100 justify-content-center align-items-center fst-italic text-center">
{{ $p.t('lehre/noLvFound') }}
</div>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<component
v-else-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<div
v-else
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:style="eventStyle(event)"
>
<component
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
</fhc-calendar>
+1
View File
@@ -36,6 +36,7 @@ export default {
"newItem",
"gridHeight",
"draggedItem",
"update:additionalRow"
],
data() {
return {
+138
View File
@@ -0,0 +1,138 @@
// TODO(chris): load events that are longer than the interval without doubling it
export function useEventLoader(rangeInterval, getPromiseFunc) {
let loading_id = 0;
const events = Vue.ref([]);
const loadingEvents = Vue.ref([]);
const allEvents = Vue.computed(() => events.value.concat(loadingEvents.value));
const lv = Vue.ref(null);
const eventsLoaded = [];
const mergePromiseArr = (n, o) => {
if (Array.isArray(n))
return o.concat(n);
return o.push(n), o;
};
const markEventsLoaded = (start, end) => {
let result = [];
if (!eventsLoaded.length) {
// empty: add new chunk
eventsLoaded.push(start.ts, end.ts);
} else {
if (eventsLoaded[eventsLoaded.length-1] + 1 == start.ts) {
// add to the end of last chunk
eventsLoaded[eventsLoaded.length-1] = end.ts;
} else if (eventsLoaded[eventsLoaded.length-1] < start.ts) {
// add new chunk after the last chunk
eventsLoaded.push(start.ts, end.ts);
} else if (eventsLoaded[0] == end.ts + 1) {
// add to the start of first chunk
eventsLoaded[0] = start.ts;
} else if (eventsLoaded[0] > end.ts) {
eventsLoaded.unshift(start.ts, end.ts);
} else {
let index = eventsLoaded.findIndex(e => e >= start.ts);
if (index % 2) {
// starts inside an existing chunk
if (eventsLoaded[index] >= end.ts)
return []; // Already loaded
let indexIsLast = (index == eventsLoaded.length - 1);
if (indexIsLast || eventsLoaded[index + 1] > end.ts) {
// extend an existing chunk
// and merge with the next if necessary
let nStart = eventsLoaded[index] + 1;
start = start.plus(nStart - start.ts);
if (!indexIsLast && eventsLoaded[index + 1] == end.ts + 1)
eventsLoaded.splice(index, 2);
else
eventsLoaded[index] = end.ts;
} else {
// merge exising chunks
// and load the rest if necessary
if (eventsLoaded[index + 2] < end.ts) {
let rStart = eventsLoaded[index + 2] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nStart = eventsLoaded[index] + 1;
start = start.plus(nStart - start.ts);
let nEnd = eventsLoaded[index + 1] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded.splice(index, 2);
}
} else {
// starts between two chunks or before the first
if (!index) {
// extend the first chunk
// and load the rest if necessary
if (eventsLoaded[1] < end.ts) {
let rStart = eventsLoaded[1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nEnd = eventsLoaded[0] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded[0] = start.ts;
} else if (eventsLoaded[index] == start.ts) {
// starts at the same position as an existing chunk
if (eventsLoaded[index + 1] >= end.ts)
return []; // Already loaded
// load the rest
let rStart = eventsLoaded[index + 1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
} else {
// extend an existing chunk
// and load the rest if necessary
if (eventsLoaded[index + 1] < end.ts) {
let rStart = eventsLoaded[index + 1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nEnd = eventsLoaded[index] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded[index] = start.ts;
}
}
}
}
if (start.ts >= end.ts)
return result;
loadingEvents.value.push({
loading_id: loading_id++,
type: "loading",
isostart: start.toISODate() + 'T' + start.toISOTime(),
isoend: end.toISODate() + 'T' + end.toISOTime()
});
return mergePromiseArr(getPromiseFunc(start, end), result);
};
Vue.watchEffect(() => {
const range = Vue.toValue(rangeInterval);
if (!(range instanceof luxon.Interval))
return;
const promises = markEventsLoaded(range.start, range.end);
Promise
.allSettled(promises)
.then(results => {
results.forEach(res => {
if (
res.status === 'fulfilled'
&& res.value.meta.status === "success"
) {
if (res.value.meta.lv)
lv.value = res.value.meta.lv;
events.value = events.value.concat(res.value.data);
loadingEvents.value = [];
}
})
});
})
return { events: allEvents, lv }
}
+49
View File
@@ -0,0 +1,49 @@
const clickListeners = [];
function saveAddClickListener(el, source, value) {
const index = clickListeners.findIndex(data => data.el == el);
if (index >= 0) {
el.removeEventListener('click', clickListeners[index].listener);
clickListeners.splice(index, 1);
}
const listener = evt => {
evt.preventDefault();
evt.stopPropagation();
const customEvent = new CustomEvent('cal-click', {
cancelable: true,
bubbles: true,
detail: { source, value }
});
evt.target.dispatchEvent(customEvent);
}
clickListeners.push({el, listener});
el.addEventListener('click', listener);
}
export default {
mounted(el, binding, vnode) {
if (binding.arg == 'container') {
el.addEventListener('cal-click', evt => {
const customEvent = new Event('click:' + evt.detail.source, {
cancelable: true
});
binding.instance.$emit('click:' + evt.detail.source, customEvent, evt.detail.value);
if (!customEvent.defaultPrevented) {
const finalEvent = new CustomEvent('cal-click-default', {
cancelable: true,
bubbles: true,
detail: evt.detail
});
evt.target.dispatchEvent(finalEvent);
}
});
} else {
saveAddClickListener(el, binding.arg, binding.value);
}
},
updated(el, binding, vnode, prevVnode) {
if (binding.arg != 'container') {
saveAddClickListener(el, binding.arg, binding.value);
}
}
}
@@ -0,0 +1,100 @@
/**
* TODO(chris): This needs serious rework!!!
*/
export default {
mounted(el, binding, vnode) {
if (binding.arg == 'draggable') {
el.addEventListener('update-my-value', evt => {
evt.preventDefault();
binding.value = evt.detail.item;
});
el.addEventListener('dragstart', evt => {
el.dispatchEvent(new CustomEvent('calendar-dragstart', {
cancelable: true,
bubbles: true,
detail: {
item: binding.value,
x: evt.offsetX / el.offsetWidth,
y: evt.offsetY / el.offsetHeight,
originalEvent: evt
}
}));
});
el.addEventListener('dragend', evt => {
el.dispatchEvent(new CustomEvent('calendar-dragend', {
cancelable: true,
bubbles: true,
detail: {
item: binding.value,
originalEvent: evt
}
}));
});
} else if (binding.arg == 'dropcage') {
let hitbox = null;
el.addEventListener('dragover', evt => {
if (hitbox)
return;
hitbox = el.getBoundingClientRect();
return el.dispatchEvent(new CustomEvent('calendar-dragenter', {
detail: { originalEvent: evt }
}));
});
window.addEventListener('dragleave', evt => {
if (!hitbox)
return;
let pos;
if (typeof evt.clientX === 'undefined')
pos = {
x: evt.pageX + document.documentElement.scrollLeft,
y: evt.pageY + document.documentElement.scrollTop
};
else
pos = {
x: evt.clientX + document.body.scrollLeft + document.documentElement.scrollLeft,
y: evt.clientY + document.body.scrollTop + document.documentElement.scrollTop
};
if (pos.x > hitbox.left + hitbox.width - 1 || pos.x < hitbox.left || pos.y > hitbox.top + hitbox.height - 1 || pos.y < hitbox.top) {
hitbox = null;
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
detail: { originalEvent: evt }
}));
}
});
window.addEventListener('drop', evt => {
if (!hitbox)
return;
hitbox = null;
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
detail: { originalEvent: evt }
}));
});
} else if (binding.arg == 'dropzone') {
el.addEventListener(
binding.modifiers.once ? 'dragenter' : 'dragover',
evt => {
const timestamp = binding.value instanceof Function
? binding.value(evt)
: binding.value;
const detail = timestamp.timestamp ? timestamp : { timestamp };
el.dispatchEvent(new CustomEvent('calendar-dragchange', {
cancelable: true,
bubbles: true,
detail
}));
}
);
}
},
updated(el, binding, vnode, prevVnode) {
if (binding.arg == 'draggable') {
el.dispatchEvent(new CustomEvent('update-my-value', {
cancelable: true,
detail: {
item: binding.value
}
}));
}
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* TODO(chris): This is only a prototype!!!
*/
const DragAndDrop = {
TYPE_LE: "lehreinheit",
TYPE_VEVENT: "vevent",
getValidTransferData(event, allowedTypes) {
const json = event.dataTransfer.getData('text');
let obj;
try {
obj = JSON.parse(json);
if (!obj.type)
return null;
if (allowedTypes && !allowedTypes.includes(obj.type))
return null;
} catch (error) {
return null;
}
return obj;
},
isValidTransferData(event, allowedTypes) {
return this.getValidTransferData(event, allowedTypes) ? true : false;
},
getTransferData(event) {
const json = event.dataTransfer.getData('text');
return JSON.parse(json);
},
setTransferData(event, data) {
switch (data.type) {
case DragAndDrop.TYPE_LE:
data = DragAndDrop.fromLe(data);
break;
default:
if (data.dtstart && data.dtend && data.uid && data.summary) {
data = DragAndDrop.fromVEvent(data);
break;
}
return false; // No type found => abort
}
event.dataTransfer.setData('text', JSON.stringify(data));
return true;
},
fromLe(data) {
const {
type = DragAndDrop.TYPE_LE,
lehreinheit_id: id,
stundenblockung
} = data;
return { type, id, stundenblockung };
},
fromVEvent(data) {
const {
type = DragAndDrop.TYPE_VEVENT,
uid: id,
dtstart,
dtend,
summary
} = data;
return { type, id, dtstart, dtend, summary };
}
};
export default DragAndDrop;
+62
View File
@@ -38720,6 +38720,68 @@ array(
)
),
//**************************** CORE/calendar
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'kw',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'W',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'W',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'year_kw',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{year} KW {week}',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{year} W {week}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'today',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Heute',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'today',
'description' => '',
'insertvon' => 'system'
)
)
),
//**************************** FHC-Core-SAP
array(
'app' => 'core',