diff --git a/application/config/calendar.php b/application/config/calendar.php new file mode 100644 index 000000000..99bb9b2c6 --- /dev/null +++ b/application/config/calendar.php @@ -0,0 +1,6 @@ + ['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']); diff --git a/application/controllers/Cis4.php b/application/controllers/Cis4.php index c0ca8d503..b7ba2029d 100644 --- a/application/controllers/Cis4.php +++ b/application/controllers/Cis4.php @@ -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']); } -} \ No newline at end of file +} diff --git a/application/controllers/CisVue/Cms.php b/application/controllers/CisVue/Cms.php index a9ff13c53..aa07b919f 100644 --- a/application/controllers/CisVue/Cms.php +++ b/application/controllers/CisVue/Cms.php @@ -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']); } diff --git a/application/controllers/api/frontend/v1/LvPlan.php b/application/controllers/api/frontend/v1/LvPlan.php index 69d08110a..0ccf65a0e 100644 --- a/application/controllers/api/frontend/v1/LvPlan.php +++ b/application/controllers/api/frontend/v1/LvPlan.php @@ -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); } diff --git a/application/libraries/StundenplanLib.php b/application/libraries/StundenplanLib.php index 8bc5a7c70..0107ceef1 100644 --- a/application/libraries/StundenplanLib.php +++ b/application/libraries/StundenplanLib.php @@ -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 diff --git a/application/views/CisRouterView/CisRouterView.php b/application/views/CisRouterView/CisRouterView.php index f70274f6e..ab22fbb81 100644 --- a/application/views/CisRouterView/CisRouterView.php +++ b/application/views/CisRouterView/CisRouterView.php @@ -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); ?> -
> +
> diff --git a/composer.json b/composer.json index c0dd03abf..370c2eda1 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index 70044eb58..f2ce3923c 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/public/css/Cis4/CoreCalendarEvents.css b/public/css/Cis4/CoreCalendarEvents.css index d886e024e..c990c93d2 100644 --- a/public/css/Cis4/CoreCalendarEvents.css +++ b/public/css/Cis4/CoreCalendarEvents.css @@ -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; -} \ No newline at end of file +} diff --git a/public/css/components/calendar.css b/public/css/components/calendar.css index cb570cc46..f46554007 100644 --- a/public/css/components/calendar.css +++ b/public/css/components/calendar.css @@ -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; -} \ No newline at end of file diff --git a/public/css/components/calendar/backgrounds.css b/public/css/components/calendar/backgrounds.css new file mode 100644 index 000000000..2524be91f --- /dev/null +++ b/public/css/components/calendar/backgrounds.css @@ -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; +} + diff --git a/public/css/components/calendar/events.css b/public/css/components/calendar/events.css new file mode 100644 index 000000000..f996b7339 --- /dev/null +++ b/public/css/components/calendar/events.css @@ -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%); +} diff --git a/public/css/components/calendar/frame.css b/public/css/components/calendar/frame.css new file mode 100644 index 000000000..ab400c95d --- /dev/null +++ b/public/css/components/calendar/frame.css @@ -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; +} diff --git a/public/css/components/dashboard.css b/public/css/components/dashboard.css index ae2bb252f..8dc19128d 100644 --- a/public/css/components/dashboard.css +++ b/public/css/components/dashboard.css @@ -1,5 +1,6 @@ @import './calendar.css'; @import './dashboard/news.css'; +@import './dashboard/LvPlan.css'; :root{ --fhc-dashboard-danger: var(--fhc-danger, #842029); diff --git a/public/css/components/dashboard/LvPlan.css b/public/css/components/dashboard/LvPlan.css new file mode 100644 index 000000000..6e0e4a654 --- /dev/null +++ b/public/css/components/dashboard/LvPlan.css @@ -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; +} \ No newline at end of file diff --git a/public/js/api/factory/lvPlan.js b/public/js/api/factory/lvPlan.js index d8cad1c01..99f7961f6 100644 --- a/public/js/api/factory/lvPlan.js +++ b/public/js/api/factory/lvPlan.js @@ -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 } }; }, diff --git a/public/js/api/lvPlan.js b/public/js/api/lvPlan.js index a1d34b37c..2939b688c 100644 --- a/public/js/api/lvPlan.js +++ b/public/js/api/lvPlan.js @@ -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 } ); }, diff --git a/public/js/components/Calendar/Base.js b/public/js/components/Calendar/Base.js new file mode 100644 index 000000000..520948ecb --- /dev/null +++ b/public/js/components/Calendar/Base.js @@ -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 */` +
+ + + + + + + + + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Base/DragAndDrop.js b/public/js/components/Calendar/Base/DragAndDrop.js new file mode 100644 index 000000000..0be17ddb4 --- /dev/null +++ b/public/js/components/Calendar/Base/DragAndDrop.js @@ -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: ` +
+ +
+ ` +} diff --git a/public/js/components/Calendar/Base/Grid.js b/public/js/components/Calendar/Base/Grid.js new file mode 100644 index 000000000..37b31e784 --- /dev/null +++ b/public/js/components/Calendar/Base/Grid.js @@ -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 */` +
+
+
+ +
+
+
+
+ + + +
+
+
+
+
+ +
+ +
+ +
+
+
+
+ ` +} diff --git a/public/js/components/Calendar/Base/Grid/Line.js b/public/js/components/Calendar/Base/Grid/Line.js new file mode 100644 index 000000000..9a6c2f579 --- /dev/null +++ b/public/js/components/Calendar/Base/Grid/Line.js @@ -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 */` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Base/Grid/Line/Background.js b/public/js/components/Calendar/Base/Grid/Line/Background.js new file mode 100644 index 000000000..e87737ffc --- /dev/null +++ b/public/js/components/Calendar/Base/Grid/Line/Background.js @@ -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 */` +
+ {{ background.label }} +
+ ` +} diff --git a/public/js/components/Calendar/Base/Grid/Line/Event.js b/public/js/components/Calendar/Base/Grid/Line/Event.js new file mode 100644 index 000000000..6512d1fa1 --- /dev/null +++ b/public/js/components/Calendar/Base/Grid/Line/Event.js @@ -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 */` +
+ + {{ event.orig }} + +
+ ` +} diff --git a/public/js/components/Calendar/Base/Header.js b/public/js/components/Calendar/Base/Header.js new file mode 100644 index 000000000..c1dae4bc0 --- /dev/null +++ b/public/js/components/Calendar/Base/Header.js @@ -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 */` +
+
+
+ +
+
+
+ + + +
+
+
+
+ + + + +
+
+
+
+ ` +} diff --git a/public/js/components/Calendar/Base/Header/Datepicker.js b/public/js/components/Calendar/Base/Header/Datepicker.js new file mode 100644 index 000000000..2d3237643 --- /dev/null +++ b/public/js/components/Calendar/Base/Header/Datepicker.js @@ -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 */` + + ` +} diff --git a/public/js/components/Calendar/Base/Label/Day.js b/public/js/components/Calendar/Base/Label/Day.js new file mode 100644 index 000000000..731beebd2 --- /dev/null +++ b/public/js/components/Calendar/Base/Label/Day.js @@ -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 */` +
+ {{ titleFull }} + {{ titleLong }} + {{ titleShort }} + {{ titleNarrow }} +
+ ` +} diff --git a/public/js/components/Calendar/Base/Label/Dow.js b/public/js/components/Calendar/Base/Label/Dow.js new file mode 100644 index 000000000..4da02de64 --- /dev/null +++ b/public/js/components/Calendar/Base/Label/Dow.js @@ -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 */` +
+ {{ titleLong }} + {{ titleShort }} + {{ titleNarrow }} +
+ ` +} diff --git a/public/js/components/Calendar/Base/Label/Time.js b/public/js/components/Calendar/Base/Label/Time.js new file mode 100644 index 000000000..a7fc21ae7 --- /dev/null +++ b/public/js/components/Calendar/Base/Label/Time.js @@ -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: ` +
+ {{ start }} + - + {{ end }} +
+ ` +} diff --git a/public/js/components/Calendar/Base/Label/Week.js b/public/js/components/Calendar/Base/Label/Week.js new file mode 100644 index 000000000..76849f2c6 --- /dev/null +++ b/public/js/components/Calendar/Base/Label/Week.js @@ -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: ` +
+ + {{ week.number }} + +
+ ` +} diff --git a/public/js/components/Calendar/Base/Slider.js b/public/js/components/Calendar/Base/Slider.js new file mode 100644 index 000000000..192eccd5f --- /dev/null +++ b/public/js/components/Calendar/Base/Slider.js @@ -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 */` +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ ` +} diff --git a/public/js/components/Calendar/Mode/Day.js b/public/js/components/Calendar/Mode/Day.js new file mode 100644 index 000000000..68ca1fa89 --- /dev/null +++ b/public/js/components/Calendar/Mode/Day.js @@ -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: ` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Mode/Day/View.js b/public/js/components/Calendar/Mode/Day/View.js new file mode 100644 index 000000000..09738f1d0 --- /dev/null +++ b/public/js/components/Calendar/Mode/Day/View.js @@ -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 */` +
+ + + + + + +
+ {{ emptyMessage }} +
+
+
+
+ +
+

{{ emptyMessageDetails }}

+ +
+
+ ` +} diff --git a/public/js/components/Calendar/Mode/List.js b/public/js/components/Calendar/Mode/List.js new file mode 100644 index 000000000..97990a5a3 --- /dev/null +++ b/public/js/components/Calendar/Mode/List.js @@ -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: ` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Mode/List/View.js b/public/js/components/Calendar/Mode/List/View.js new file mode 100644 index 000000000..76b4c887d --- /dev/null +++ b/public/js/components/Calendar/Mode/List/View.js @@ -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 */` +
+
+ +
+
+
+ + , + +
+
+
+ +
+
+ +
+
+
+
+ ` +} diff --git a/public/js/components/Calendar/Mode/Month.js b/public/js/components/Calendar/Mode/Month.js new file mode 100644 index 000000000..a352ccc98 --- /dev/null +++ b/public/js/components/Calendar/Mode/Month.js @@ -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: ` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Mode/Month/View.js b/public/js/components/Calendar/Mode/Month/View.js new file mode 100644 index 000000000..ea83b6c96 --- /dev/null +++ b/public/js/components/Calendar/Mode/Month/View.js @@ -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 */` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Mode/Week.js b/public/js/components/Calendar/Mode/Week.js new file mode 100644 index 000000000..f8653b9e9 --- /dev/null +++ b/public/js/components/Calendar/Mode/Week.js @@ -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: ` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Calendar/Mode/Week/View.js b/public/js/components/Calendar/Mode/Week/View.js new file mode 100644 index 000000000..612c4ad55 --- /dev/null +++ b/public/js/components/Calendar/Mode/Week/View.js @@ -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 */` +
+ + + + + +
+ ` +} diff --git a/public/js/components/Cis/LvPlan/LvPlan.js b/public/js/components/Cis/LvPlan/LvPlan.js index 14c9b77a2..cdb2c38c1 100644 --- a/public/js/components/Cis/LvPlan/LvPlan.js +++ b/public/js/components/Cis/LvPlan/LvPlan.js @@ -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*/` - + +
` +}; diff --git a/public/js/components/Cis/Mylv/LvMenu.js b/public/js/components/Cis/Mylv/LvMenu.js index 06a63915d..88340f01b 100644 --- a/public/js/components/Cis/Mylv/LvMenu.js +++ b/public/js/components/Cis/Mylv/LvMenu.js @@ -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">

{{menuItem.name}}

- {{text}}

+
{{text}}