diff --git a/application/controllers/api/frontend/v1/tempus/Config.php b/application/controllers/api/frontend/v1/tempus/Config.php index 1f96d240a..0e7e4e20e 100644 --- a/application/controllers/api/frontend/v1/tempus/Config.php +++ b/application/controllers/api/frontend/v1/tempus/Config.php @@ -31,7 +31,7 @@ class Config extends FHCAPI_Controller $config = []; - $result = $this->_ci->VariableModel->getVariables(getAuthUID(), ['ignore_kollision', 'kollision_student', 'ignore_reservierung', 'ignore_zeitsperre']); + $result = $this->_ci->VariableModel->getVariables(getAuthUID(), ['ignore_kollision', 'kollision_student', 'ignore_reservierung', 'ignore_zeitsperre', 'ignore_resources_collisions']); $data = $this->getDataOrTerminateWithError($result); $config['ignore_kollision'] = [ @@ -60,7 +60,11 @@ class Config extends FHCAPI_Controller "value" => ($data['ignore_zeitsperre'] ?? 'false') === 'true' ]; - + $config['ignore_resources_collisions'] = [ + "type" => "checkbox", + "label" => $this->p->t('ui', 'ignore_resources_collisions'), + "value" => ($data['ignore_resources_collisions'] ?? 'false') === 'true' + ]; $this->terminateWithSuccess($config); } @@ -109,6 +113,11 @@ class Config extends FHCAPI_Controller 'ignore_zeitsperre', $this->input->post('ignore_zeitsperre') === true ? 'true' : 'false' ); + $this->_ci->VariableModel->setVariable( + getAuthUID(), + 'ignore_resources_collisions', + $this->input->post('ignore_resources_collisions') === true ? 'true' : 'false' + ); $this->terminateWithSuccess(); } diff --git a/application/controllers/api/frontend/v1/tempus/OperationalResourceToCalenderAPI.php b/application/controllers/api/frontend/v1/tempus/OperationalResourceToCalenderAPI.php new file mode 100644 index 000000000..15599ba34 --- /dev/null +++ b/application/controllers/api/frontend/v1/tempus/OperationalResourceToCalenderAPI.php @@ -0,0 +1,84 @@ + ['admin:r', 'assistenz:r'], + 'storeResourcesToCalendarRelationship' => ['admin:w', 'assistenz:w'], + 'getSchedulableResourcesByCalendar' => ['admin:r', 'assistenz:r'], + ]); + + $this->load->model('ressource/BetriebsmittelKalender_model', 'BetriebsmittelKalenderModel'); + $this->load->model('ressource/Betriebsmittel_model', 'BetriebsmittelModel'); + $this->load->model('ressource/Kalender_model', 'KalenderModel'); + + $this->load->library('CollisionChecker'); + $this->load->library('KalenderLib'); + } + + //------------------------------------------------------------------------------------------------------------------ + // Public methods + public function getSchedulableResourcesByCalendar($calenderID) + { + if (!isset($calenderID)) $this->terminateWithError("Missing required parameter 'kalender_id'"); + + $result = $this->KalenderModel->loadWhere(['kalender_id' => $calenderID]); + if (isError($result)) $this->terminateWithError("Calendar with id '$calenderID' does not have a valid group id"); + + $calender = $this->getDataOrTerminateWithError($result)[0]; + if (!isset($calender)) $this->terminateWithError("Calendar with id '$calenderID' not found"); + + $result = $this->BetriebsmittelModel->getSchedulableEntriesByDatetimeInterval($calender->von, $calender->bis); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + public function getAssignedResourcesByCalenderId($calenderID) + { + if (!isset($calenderID)) $this->terminateWithError("Missing required parameter 'kalender_id'"); + + $result = $this->KalenderModel->loadWhere(['kalender_id' => $calenderID]); + if (empty($result)) $this->terminateWithError("Calendar with id '$calenderID' not found"); + + $calenderGroupID = $this->getDataOrTerminateWithError($result)[0]->eindeutige_gruppen_id; + if (!isset($calenderGroupID)) $this->terminateWithError("Calendar with id '$calenderID' does not have a valid group id"); + + + $this->BetriebsmittelKalenderModel->addSelect(['tbl_betriebsmittel_kalender.*', 'tbl_betriebsmittel.beschreibung', 'tbl_betriebsmittel.verplanen']); + $this->BetriebsmittelKalenderModel->addJoin('wawi.tbl_betriebsmittel', 'betriebsmittel_id'); + $result = $this->BetriebsmittelKalenderModel->loadWhere([ + 'eindeutige_kalender_gruppen_id' => $calenderGroupID, + 'tbl_betriebsmittel.verplanen' => true, + ]); + + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + public function storeResourcesToCalendarRelationship() + { + $calenderID = $this->input->post('kalender_id'); + $assignedResources = $this->input->post('assignedResources'); + + if (!isset($calenderID)) $this->terminateWithError("Missing required parameter 'kalender_id'", 'general'); + if (!isset($assignedResources)) $this->terminateWithError("Missing required parameter 'assignedResources'", 'general'); + + $result = $this->KalenderModel->loadWhere(['kalender_id' => $calenderID]); + if (empty($result)) $this->terminateWithError("Calendar with id '$calenderID' not found"); + + $calendar = $this->getDataOrTerminateWithError($result)[0]; + + $calenderGroupID = $calendar->eindeutige_gruppen_id; + if (!isset($calenderGroupID)) $this->terminateWithError("Calendar with id '$calenderID' does not have a valid group id"); + + $result = $this->kalenderlib->addOperationalResourcesToKalenderEvent($calendar, $assignedResources); + if (isError($result)) + $this->terminateWithError(getError($result)); + + $this->terminateWithSuccess(['message' => 'Resources assigned successfully']); + } +} diff --git a/application/controllers/system/MigrateKalender.php b/application/controllers/system/MigrateKalender.php index 95d2451d3..09254f55b 100644 --- a/application/controllers/system/MigrateKalender.php +++ b/application/controllers/system/MigrateKalender.php @@ -19,6 +19,7 @@ class MigrateKalender extends Auth_Controller parent::__construct(array( 'migrateStundenplan' => ['admin:rw'], 'migrateReservierung' => ['admin:rw'], + 'migrateStundenplanBetriebsmittelEntries' => ['admin:rw'] )); $this->load->model('ressource/Kalender_model', 'KalenderModel'); $this->load->model('ressource/Kalender_Lehreinheit_model', 'KalenderLehreinheitModel'); @@ -147,66 +148,6 @@ class MigrateKalender extends Auth_Controller $this->_insertSync($fehlende, $kalender_id); } } - /*if(hasData($SyncResult)) - { - //bereits vorhanden - // TODO Update - } - else - { - // Neuen Eintrag anlegen - - $von = $rowstpl->datum.' '.$rowstpl->beginn; - $bis = $rowstpl->datum.' '.$rowstpl->ende; - $typ = 'lehreinheit'; - $status = 'live'; - $insertamum = $rowstpl->insertamum; - $insertvon = $rowstpl->insertvon; - $updateamum = $rowstpl->updateamum; - $updatevon = $rowstpl->updatevon; - - $resultKalenderInsert = $this->KalenderModel->insert( - array( - 'von' => $von, - 'bis' => $bis, - 'typ' => $typ, - 'status_kurzbz' => $status, - 'vorgaenger_kalender_id' => null, - 'insertamum' => $insertamum, - 'insertvon' => $insertvon, - 'updateamum' => $updateamum, - 'updatevon' => $updatevon - ) - ); - - if(isSuccess($resultKalenderInsert)) - { - $kalender_id = getData($resultKalenderInsert); - - $resultKalenderInsert = $this->KalenderLehreinheitModel->insert( - array( - 'kalender_id' => $kalender_id, - 'lehreinheit_id' => $rowstpl->lehreinheit_id, - ) - ); - - $resultKalenderInsert = $this->KalenderOrtModel->insert( - array( - 'kalender_id' => $kalender_id, - 'ort_kurzbz' => $rowstpl->ort_kurzbz, - ) - ); - - $resultSyncInsert = $this->SyncModel->insert( - array( - 'stundenplandev_id' => $rowstpl->stundenplandev_id, - 'kalender_id' => $kalender_id, - 'lastupdate' => date('Y-m-d H:i:s') - ) - ); - - } - }*/ } } } @@ -267,58 +208,6 @@ class MigrateKalender extends Auth_Controller JOIN lehre.tbl_stunde s_von ON s_von.stunde = b.stunde_von JOIN lehre.tbl_stunde s_bis ON s_bis.stunde = b.stunde_bis ORDER BY b.reservierung_id DESC;"; - /*$qry = "WITH per_stunde AS ( - SELECT - datum, - titel, - beschreibung, ort_kurzbz, studiengang_kz, stunde, - veranstaltung_id, - array_agg(DISTINCT uid) AS uids, - array_agg(DISTINCT reservierung_id::text) AS reservierung_ids, - array_agg(DISTINCT ROW(semester, verband, gruppe)::text) AS svg_kombis, - array_agg(DISTINCT gruppe_kurzbz) AS gruppen_kurzbz, - MIN(reservierung_id) AS reservierung_id, - MAX(insertamum) AS insertamum, - MAX(insertvon) AS insertvon - FROM campus.tbl_reservierung - WHERE datum >= ? AND datum <= ? - GROUP BY datum, titel, beschreibung, ort_kurzbz, studiengang_kz, stunde, veranstaltung_id - ), - numbered AS ( - SELECT - per_stunde.*, - stunde - ROW_NUMBER() OVER (PARTITION BY datum, titel, beschreibung, ort_kurzbz, studiengang_kz, veranstaltung_id ORDER BY stunde) AS grp - FROM per_stunde - ), - grouped AS ( - SELECT - MIN(reservierung_id) AS reservierung_id, - ort_kurzbz, studiengang_kz, datum, - MIN(stunde) AS stunde_von, - MAX(stunde) AS stunde_bis, - titel, beschreibung, - array_agg(DISTINCT gruppe_kurzbz_elem) AS gruppen_kurzbz, - array_agg(DISTINCT uid_elem) AS uids, - array_agg(DISTINCT res_id) AS reservierung_ids, - array_agg(DISTINCT svg_elem) AS svg_kombis, - veranstaltung_id, - MAX(insertamum) AS insertamum, - MAX(insertvon) AS insertvon - FROM numbered, - unnest(uids) AS uid_elem, - unnest(reservierung_ids) AS res_id, - unnest(gruppen_kurzbz) AS gruppe_kurzbz_elem, - unnest(svg_kombis) AS svg_elem - GROUP BY datum, titel, beschreibung, ort_kurzbz, studiengang_kz, veranstaltung_id, grp - ) - SELECT - grouped.*, - (datum + s_von.beginn) AS von, - (datum + s_bis.ende) AS bis - FROM grouped - JOIN lehre.tbl_stunde s_von ON s_von.stunde = grouped.stunde_von - JOIN lehre.tbl_stunde s_bis ON s_bis.stunde = grouped.stunde_bis - ORDER BY grouped.reservierung_id DESC";*/ array_push($params, $von, $bis); $reservierung_data = $db->execReadOnlyQuery($qry, $params); @@ -367,6 +256,107 @@ class MigrateKalender extends Auth_Controller } } + public function migrateStundenplanBetriebsmittelEntries() { + $this->setKalendarEntriesGroupIDs(); + $this->setKalendarEntriesGroupIDsForChildren(); + + $dbModel = new DB_Model(); + + $deleteOldImportedTempusEntriesQuery = "DELETE from lehre.tbl_betriebsmittel_kalender WHERE quelle != 'tempus_neu' OR quelle IS NULL;"; + $createHelperTypeQuery = "DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'betriebsmittel_info' + ) THEN + CREATE TYPE betriebsmittel_info AS ( + betriebsmittel_id bigint, + insertamum timestamp, + insertvon text + ); + END IF; + END $$;"; + + $query = $deleteOldImportedTempusEntriesQuery . + $createHelperTypeQuery . + "WITH test AS ( + SELECT + tk.eindeutige_gruppen_id AS eindeutige_gruppen_id, + array_agg(tk.kalender_id), + array_agg( + ROW(tsb.betriebsmittel_id, tsb.insertamum, tsb.insertvon)::betriebsmittel_info + ) AS betriebsmittel_data + FROM sync.tbl_stundenplandev_kalender AS sk + JOIN lehre.tbl_kalender tk ON tk.kalender_id = sk.kalender_id + JOIN lehre.tbl_stundenplan_betriebsmittel tsb ON tsb.stundenplandev_id = sk.stundenplandev_id + GROUP BY tk.eindeutige_gruppen_id + ) + INSERT INTO lehre.tbl_betriebsmittel_kalender ( + eindeutige_kalender_gruppen_id, + betriebsmittel_id, + insertamum, + insertvon + ) + SELECT + t.eindeutige_gruppen_id, + bm.betriebsmittel_id, + bm.insertamum, + bm.insertvon + FROM test t + CROSS JOIN LATERAL unnest(t.betriebsmittel_data) AS bm + ON CONFLICT (eindeutige_kalender_gruppen_id, betriebsmittel_id) DO NOTHING; + ;"; + + $dbModel->db->query($query); + } + + private function setKalendarEntriesGroupIDs() { + $dbModel = new DB_Model(); + + $query = "UPDATE lehre.tbl_kalender + SET eindeutige_gruppen_id = gen_random_uuid() + WHERE vorgaenger_kalender_id IS NULL + AND eindeutige_gruppen_id IS NULL;"; + + $dbModel->db->query($query); + + } + + private function setKalendarEntriesGroupIDsForChildren() { + $dbModel = new DB_Model(); + + $query = "WITH RECURSIVE tree AS + ( + SELECT + kalender_id, + vorgaenger_kalender_id, + kalender_id AS root_id, + eindeutige_gruppen_id as root_eindeutige_gruppen_id + FROM lehre.tbl_kalender + WHERE vorgaenger_kalender_id IS NULL + + UNION ALL + + SELECT + i.kalender_id, + i.vorgaenger_kalender_id, + t.root_id, + t.root_eindeutige_gruppen_id + FROM lehre.tbl_kalender i + JOIN tree t + ON i.vorgaenger_kalender_id = t.kalender_id + where i.eindeutige_gruppen_id is NULL + + ) + UPDATE lehre.tbl_kalender k + SET eindeutige_gruppen_id = t.root_eindeutige_gruppen_id + FROM tree t + WHERE k.kalender_id = t.kalender_id + AND k.eindeutige_gruppen_id IS NULL;"; + + $dbModel->db->query($query); + } private function _insertKalender($block, $typ) { diff --git a/application/helpers/hlp_common_helper.php b/application/helpers/hlp_common_helper.php index 06cfa1cfd..d932a8243 100644 --- a/application/helpers/hlp_common_helper.php +++ b/application/helpers/hlp_common_helper.php @@ -570,3 +570,14 @@ function buildDropdownEntryPrintArray($id, $name, $parameterurl, $uid=null, $ord ]; } + +function generateUUID() +{ + $data = openssl_random_pseudo_bytes(16); + + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); +} + diff --git a/application/libraries/CollisionChecker.php b/application/libraries/CollisionChecker.php index b7921a6d9..ac9152d2b 100644 --- a/application/libraries/CollisionChecker.php +++ b/application/libraries/CollisionChecker.php @@ -18,10 +18,12 @@ class CollisionChecker $this->_ci->load->library('collision/checks/LectureCollisionCheck'); $this->_ci->load->library('collision/checks/VerbandCollisionCheck'); $this->_ci->load->library('collision/checks/StudentCollisionCheck'); + $this->_ci->load->library('collision/checks/ResourcesCollisionCheck'); $this->register($this->_ci->roomcollisioncheck); $this->register($this->_ci->lecturecollisioncheck); $this->register($this->_ci->verbandcollisioncheck); $this->register($this->_ci->studentcollisioncheck); + $this->register($this->_ci->resourcescollisioncheck); Events::trigger('collision_register', $this); } diff --git a/application/libraries/KalenderLib.php b/application/libraries/KalenderLib.php index 3b5c60e01..108447c37 100644 --- a/application/libraries/KalenderLib.php +++ b/application/libraries/KalenderLib.php @@ -14,6 +14,7 @@ class KalenderLib $this->_ci->load->model('ressource/Kalender_Event_model', 'KalenderEventModel'); $this->_ci->load->model('ressource/Kalender_Event_Teilnehmer_model', 'KalenderEventTeilnehmerModel'); $this->_ci->load->model('ressource/Kalender_Ort_model', 'KalenderOrtModel'); + $this->_ci->load->model('ressource/BetriebsmittelKalender_model', 'BetriebsmittelKalenderModel'); $this->_ci->load->model('education/Lehreinheit_model', 'LehreinheitModel'); $this->_ci->load->model('education/Lehrveranstaltung_model', 'LehrveranstaltungModel'); $this->_ci->load->model('education/LehreinheitMitarbeiter_model', 'LehreinheitMitarbeiterModel'); @@ -174,7 +175,8 @@ class KalenderLib 'titel' => isset($row->titel) ? $row->titel : '', 'beschreibung' => isset($row->beschreibung) ? $row->beschreibung : '', 'topic' => (isset($row->lehrfach_kurzbz) ? $row->lehrfach_kurzbz : '').' '.(isset($row->lehrform_kurzbz) ? $row->lehrform_kurzbz : ''), - 'collisions' => false + 'collisions' => false, + 'has_assigned_resources' => isset($row->has_assigned_resources) ? $row->has_assigned_resources : false ]; } @@ -345,6 +347,14 @@ class KalenderLib WHERE nachfolger.vorgaenger_kalender_id = tbl_kalender.kalender_id)', null, false); $this->_ci->KalenderModel->db->where_not_in('status_kurzbz', array('deleted')); + + $this->_ci->KalenderModel->addSelect([ + "( + SELECT COUNT(*) FROM lehre.tbl_betriebsmittel_kalender + WHERE tbl_betriebsmittel_kalender.eindeutige_kalender_gruppen_id = tbl_kalender.eindeutige_gruppen_id + ) AS has_assigned_resources" + ]); + $data = $this->_ci->KalenderModel->load(); return $this->_mapEvents($data); @@ -354,6 +364,7 @@ class KalenderLib { $this->_getBasePlan($start_date, $end_date); + $this->_ci->KalenderModel->db->where('status_kurzbz', 'live'); $data = $this->_ci->KalenderModel->load(); @@ -495,6 +506,7 @@ class KalenderLib 'bis' => $end_date, 'typ' => 'lehreinheit', 'status_kurzbz' => 'planning', + 'eindeutige_gruppen_id' => $this->_ci->KalenderModel->generateUniqueGroupId(), 'insertvon' => getAuthUID(), 'insertamum' => date('Y-m-d H:i:s') ) @@ -658,6 +670,81 @@ class KalenderLib return $kalenderresult; } + public function addOperationalResourcesToKalenderEvent($calendar, $assignedResources) + { + $checkerData = new stdClass(); + $checkerData->kalender_id = $calendar->kalender_id; + $checkerData->betriebsmittel_ids = []; + $checkerData->von = $calendar->von; + $checkerData->bis = $calendar->bis; + + foreach ($assignedResources as $assignedResource) { + array_push($checkerData->betriebsmittel_ids, $assignedResource['betriebsmittel_id']); + } + + $errors = []; + $result = $this->_ci->collisionchecker->run($checkerData); + if (!empty($result)) + { + $errors = $result; + } + + if (!empty($errors)) { + return error($errors); + } + + $this->_ci->db->trans_start(); + + $allowedResourceIDs = array_filter(array_map( + function ($resource) { + return $resource['betriebsmittel_id'] ?? null; + }, + $assignedResources + )); + + if (!empty($allowedResourceIDs)) { + $placeholders = implode(',', array_fill(0, count($allowedResourceIDs), '?')); + + $query = " + DELETE FROM lehre.tbl_betriebsmittel_kalender + WHERE eindeutige_kalender_gruppen_id = ? + AND betriebsmittel_id NOT IN ($placeholders) + "; + + $params = array_merge( + [$calendar->eindeutige_gruppen_id], + $allowedResourceIDs + ); + + $this->_ci->db->query($query, $params); + } else { + $this->_ci->db->where('eindeutige_kalender_gruppen_id', $calendar->eindeutige_gruppen_id); + $this->_ci->db->delete('lehre.tbl_betriebsmittel_kalender'); + } + + foreach ($assignedResources as $assignedResource) { + $data = [ + 'eindeutige_kalender_gruppen_id' => $calendar->eindeutige_gruppen_id, + 'betriebsmittel_id' => $assignedResource['betriebsmittel_id'], + 'anmerkung' => $assignedResource['anmerkung'] ?? null, + 'quelle' => 'tempus_neu', + 'updateamum' => date('c'), + 'updatevon' => getAuthUid(), + ]; + if (isset($assignedResource['betriebsmittel_kalender_id'])) { + $result = $this->_ci->BetriebsmittelKalenderModel->update($assignedResource['betriebsmittel_kalender_id'], $data); + } else { + $result = $this->_ci->BetriebsmittelKalenderModel->insert(array_merge($data, [ + 'insertvon' => getAuthUid(), + 'updateamum' => date('c'), + ])); + } + } + $this->_ci->db->trans_complete(); + + return success('Operational resources added to calendar event successfully.'); + } + private function _addTeilnehmerToEvent($kalender_id, $uid, $rolle) { return $this->_ci->KalenderEventTeilnehmerModel->insert( @@ -748,9 +835,22 @@ class KalenderLib $entryResult = $this->_loadKalenderEntry($kalender_id); if (isError($entryResult)) return $entryResult; + $kalender_entry = getData($entryResult); + + $calendarResources = $this->_ci->BetriebsmittelKalenderModel->loadWhere(['eindeutige_kalender_gruppen_id' => $kalender_entry->eindeutige_gruppen_id]); + if (isError($calendarResources)) return $calendarResources; + + $calendarResourcesItems = getData($calendarResources); + $calendarResourcesIDs = []; + if (is_array($calendarResourcesItems)) { + $calendarResourcesIDs = array_map(function($resource) { + return $resource->betriebsmittel_id; + }, getData($calendarResources)); + } $kalender_entry->ort_kurzbz = $ort_kurzbz ?? $kalender_entry->ort_kurzbz; + $kalender_entry->betriebsmittel_ids = $calendarResourcesIDs; $kalender_entry->von = $start_time ?? $kalender_entry->von; $kalender_entry->bis = $end_time ?? $kalender_entry->bis; @@ -888,6 +988,7 @@ class KalenderLib 'typ' => 'lehreinheit', 'status_kurzbz' => 'planning', 'vorgaenger_kalender_id' => $old_id, + 'eindeutige_gruppen_id' => $kalender_entry->eindeutige_gruppen_id, 'insertvon' => getAuthUID(), 'insertamum' => date('Y-m-d H:i:s') ) diff --git a/application/libraries/collision/checks/ResourcesCollisionCheck.php b/application/libraries/collision/checks/ResourcesCollisionCheck.php new file mode 100644 index 000000000..0be789b7f --- /dev/null +++ b/application/libraries/collision/checks/ResourcesCollisionCheck.php @@ -0,0 +1,85 @@ +_ci =& get_instance(); + $this->_ci->load->model('ressource/Kalender_model', 'KalenderModel'); + $this->_ci->load->library('VariableLib', array('uid' => getAuthUID())); + $this->_ci->load->library('PhrasesLib', array('ui')); + } + + public function getName() + { + return 'resources'; + } + + public function check($data) + { + if (!isset($data->betriebsmittel_ids, $data->von, $data->bis, $data->kalender_id)) return []; + + if (empty($data->betriebsmittel_ids)) return []; + + if ($this->_ci->variablelib->getVar('ignore_kollision') === 'true') return []; + if ($this->_ci->variablelib->getVar('ignore_resources_collisions') === 'true') return []; + + $this->_ci->KalenderModel->addSelect('kalender_id, von, bis, bm.beschreibung AS betriebsmittel_beschreibung'); + $this->_ci->KalenderModel->addJoin('lehre.tbl_betriebsmittel_kalender bk', 'bk.eindeutige_kalender_gruppen_id = tbl_kalender.eindeutige_gruppen_id'); + $this->_ci->KalenderModel->addJoin('wawi.tbl_betriebsmittel bm', 'bm.betriebsmittel_id = bk.betriebsmittel_id'); + + $this->_ci->KalenderModel->db->where('tbl_kalender.kalender_id !=', $data->kalender_id); + $this->_ci->KalenderModel->db->where('tbl_kalender.kalender_id NOT IN (SELECT vorgaenger_kalender_id FROM lehre.tbl_kalender WHERE vorgaenger_kalender_id IS NOT NULL)', null, false); + $this->_ci->KalenderModel->db->where_not_in('tbl_kalender.status_kurzbz', ['archived', 'deleted', 'to_delete']); + $this->_ci->KalenderModel->db->where_in('bk.betriebsmittel_id', $data->betriebsmittel_ids); + + + $result = $this->_ci->KalenderModel->loadWhere(array( + 'von <' => $data->bis, + 'bis >' => $data->von, + )); + + if (isError($result)) return []; + if (!hasData($result)) return []; + + return array_map(function($row) + { + return [ + 'errorCode' => 'resource_collision', + 'message' => $this->_ci->phraseslib->t('ui', 'resource_collision') . ': ' . $row->betriebsmittel_beschreibung . ' (' . date('d.m.Y H:i', strtotime($row->von)) . ' - ' . date('d.m.Y H:i', strtotime($row->bis)) . ')' + ]; + }, getData($result)); + } + + public function checkAll($kalender_ids) + { + if (empty($kalender_ids)) return []; + + $this->_ci->KalenderModel->addSelect('DISTINCT ON (tbl_kalender.kalender_id) tbl_kalender.kalender_id'); + $this->_ci->KalenderModel->addJoin('lehre.tbl_betriebsmittel_kalender current_bk', 'current_bk.eindeutige_kalender_gruppen_id = tbl_kalender.eindeutige_gruppen_id'); + $this->_ci->KalenderModel->addJoin('lehre.tbl_betriebsmittel_kalender other_bk', 'other_bk.betriebsmittel_id = current_bk.betriebsmittel_id'); + $this->_ci->KalenderModel->addJoin('lehre.tbl_kalender other_kalender', 'other_kalender.eindeutige_gruppen_id = other_bk.eindeutige_kalender_gruppen_id'); + + $this->_ci->KalenderModel->db->where('other_kalender.kalender_id != tbl_kalender.kalender_id', null, false); + $this->_ci->KalenderModel->db->where('other_kalender.von < tbl_kalender.bis', null, false); + $this->_ci->KalenderModel->db->where('other_kalender.bis > tbl_kalender.von', null, false); + $this->_ci->KalenderModel->db->where_not_in('other_kalender.status_kurzbz', ['archived', 'deleted', 'to_delete']); + $this->_ci->KalenderModel->db->where_in('tbl_kalender.kalender_id', $kalender_ids); + $this->_ci->KalenderModel->db->where('other_kalender.kalender_id NOT IN (SELECT vorgaenger_kalender_id FROM lehre.tbl_kalender WHERE vorgaenger_kalender_id IS NOT NULL)', null, false); + + $result = $this->_ci->KalenderModel->load(); + + if (isError($result) || !hasData($result)) return []; + + $grouped = []; + foreach (getData($result) as $row) + { + $grouped[$row->kalender_id][] = true; + } + + return $grouped; + } +} \ No newline at end of file diff --git a/application/models/ressource/BetriebsmittelKalender_model.php b/application/models/ressource/BetriebsmittelKalender_model.php new file mode 100644 index 000000000..f9a519bbf --- /dev/null +++ b/application/models/ressource/BetriebsmittelKalender_model.php @@ -0,0 +1,14 @@ +dbTable = 'lehre.tbl_betriebsmittel_kalender'; + $this->pk = 'betriebsmittel_kalender_id'; + } +} diff --git a/application/models/ressource/Betriebsmittel_model.php b/application/models/ressource/Betriebsmittel_model.php index 290c3491d..682961cee 100644 --- a/application/models/ressource/Betriebsmittel_model.php +++ b/application/models/ressource/Betriebsmittel_model.php @@ -31,4 +31,26 @@ class Betriebsmittel_model extends DB_Model return $this->execQuery($qry); } + + public function getSchedulableEntriesByDatetimeInterval($from, $to) + { + $qry = "SELECT + * + FROM + wawi.tbl_betriebsmittel + WHERE + verplanen=true + AND + NOT EXISTS( + SELECT 1 FROM lehre.tbl_betriebsmittel_kalender + JOIN lehre.tbl_kalender ON tbl_kalender.eindeutige_gruppen_id = tbl_betriebsmittel_kalender.eindeutige_kalender_gruppen_id + WHERE + betriebsmittel_id=tbl_betriebsmittel.betriebsmittel_id + AND tbl_kalender.von <= ? + AND tbl_kalender.bis >= ? + )"; + + + return $this->execQuery($qry, array($from, $to)); + } } diff --git a/application/models/ressource/Kalender_model.php b/application/models/ressource/Kalender_model.php index 2ca8e6ac1..98d8ddd65 100644 --- a/application/models/ressource/Kalender_model.php +++ b/application/models/ressource/Kalender_model.php @@ -10,5 +10,18 @@ class Kalender_model extends DB_Model parent::__construct(); $this->dbTable = 'lehre.tbl_kalender'; $this->pk = 'kalender_id'; + + $this->load->helper('hlp_common'); + } + + public function generateUniqueGroupId() + { + while (true) { + $uniqueGroupId = generateUUID(); + $result = $this->loadWhere(['eindeutige_gruppen_id' => $uniqueGroupId]); + if (!hasData($result)) { + return $uniqueGroupId; + } + } } } diff --git a/application/views/Tempus.php b/application/views/Tempus.php index 346ad56ad..f390cdb86 100644 --- a/application/views/Tempus.php +++ b/application/views/Tempus.php @@ -8,6 +8,7 @@ 'primevue3' => true, 'tabulator5' => true, 'vuedatepicker11' => true, + 'momentjs2' => true, 'phrases' => array( 'global', 'ui', diff --git a/public/js/api/factory/operationalResourceToCalender.js b/public/js/api/factory/operationalResourceToCalender.js new file mode 100644 index 000000000..9d4df6388 --- /dev/null +++ b/public/js/api/factory/operationalResourceToCalender.js @@ -0,0 +1,38 @@ +/** + * Copyright (C) 2025 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export default { + getSchedulableResourcesByCalendar(calendarID) { + return { + method: "get", + url: "/api/frontend/v1/tempus/OperationalResourceToCalenderAPI/getSchedulableResourcesByCalendar/" + calendarID, + }; + }, + getAssignedResourcesByCalenderId(calenderID) { + return { + method: "get", + url: "/api/frontend/v1/tempus/OperationalResourceToCalenderAPI/getAssignedResourcesByCalenderId/" + calenderID, + }; + }, + storeResourcesToCalendarRelationship(calenderID, assignedResources) { + return { + method: "post", + url: "/api/frontend/v1/tempus/OperationalResourceToCalenderAPI/storeResourcesToCalendarRelationship", + params: { kalender_id: calenderID, assignedResources: assignedResources }, + }; + } +}; diff --git a/public/js/components/Tempus/Renderer/Lehreinheit/calendarEvent.js b/public/js/components/Tempus/Renderer/Lehreinheit/calendarEvent.js index 5aeb224a9..da149d5ab 100644 --- a/public/js/components/Tempus/Renderer/Lehreinheit/calendarEvent.js +++ b/public/js/components/Tempus/Renderer/Lehreinheit/calendarEvent.js @@ -64,8 +64,14 @@ export default { template: /*html*/`
+
+ +
this.renderers), - appConfig: Vue.computed(() => this.appconfig), - contextMenuActions: Vue.computed(() => this.contextMenuActions), - } - }, - data() { - return { - appconfig: {}, - configEndpoints: ApiTempusConfig, - endpoint: ApiStudiengangTree, - raumVorschlaege: [], - selected: [], - searchbaroptions: { - origin: 'tempus', - cssclass: "position-relative", - calcheightonly: true, - types: [ - //"student", - "raum", - "mitarbeiter", - "mitarbeiter_ohne_zuordnung" - ], - actions: { - raum: { - defaultaction: { - type: "function", - action: this.setOrt - }, - childactions: [ - ] - }, - employee: { - defaultaction: { - type: "function", - action: (data) => { - this.setEmp(data); - } - }, - childactions: [ - ] - }, - } - }, - lv_id: null, - events: null, - minimized: false, - currentlySelectedEvent: null, - //currentDay: new Date(), - studiensemesterKurzbz: this.defaultSemester, - lists: { - nations: [], - sprachen: [], - geschlechter: [] - }, - renderers: null, - ort_kurzbz: null, - view: 'room', - parkedKeys: new Set(), - lecturers: [], - overlayCache: [], - extraBackgrounds: [], - lastRange: null, - stg: null, - show_stg: null, - semester: null, - studiensemester_kurzbz: null, - raumModal: { - show: false, - loading: false, - vorschlaege: [], - event: null - }, - visibleStatusArray: {}, - visibleStatus: ['all'], - selectedStudiensemester: this.studiensemester_kurzbz ?? this.defaultSemester, - calendarDate: luxon.DateTime.now().setZone(this.config.timezone).toISODate(), - historyEntries: [], - previewRole: 'planer' - } - }, - computed: { - contextMenuActions() { - return { - lehreinheit: [ - { - label: 'Raumauswahl', - icon: 'fa-solid fa-door-open', - action: this.openRaumauswahl - }, - { - label: 'Freischalten für Voransicht', - icon: 'fa-solid fa-chalkboard-user', - action: (orig) => this.$api.call(ApiKalender.syncToLecturer(orig.kalender_id)).then(() => this.$refs.calendar.resetEventLoader()) - }, - { - label: 'Freischalten für Live', - icon: 'fa-solid fa-user-graduate', - action: (orig) => this.$api.call(ApiKalender.syncToStudent(orig.kalender_id)).then(() => this.$refs.calendar.resetEventLoader()) - }, - { - label: 'History', - icon: 'fa-solid fa-clock-rotate-left', - action: this.openHistory - }, - { - label: 'Delete', - icon: 'fa-solid fa-calendar-xmark', - action: this.deleteEntry - }, - ], - reservierung: [ - { - label: 'Delete', - icon: 'fa-solid fa-calendar-xmark', - action: this.deleteEntry - }, - ] - }; - }, - currentDay() { - return luxon.DateTime.now().setZone(this.config.timezone).toISODate(); - }, - currentMode() { - return 'week'; - }, - visibleLecturerUids() { - if (!this.lecturers.length) - return null; - return this.lecturers.filter(lecture => lecture.showEvents).map(lecture => lecture.uid); - }, - visibleStatusOptions() { - return Object.entries(this.visibleStatusArray).map(([key, label]) => ({ key, label })); - }, - visibleStatusValue() { - if (this.visibleStatus.includes('all')) - return this.visibleStatusOptions.filter(visibleStatus => visibleStatus.key === 'all'); - return this.visibleStatus.map(status => ({ key: status, label: this.visibleStatusArray[status] })); - }, - }, - methods: { - async openRaumauswahl(orig) { - if (!orig?.kalender_id) - return; - this.raumModal = orig; - - await this.$api.call(ApiKalender.getRaumvorschlag( - orig.kalender_id - )).then(result => { - - this.raumVorschlaege = result.data ?? []; - this.$refs.raumModal.show(); - }); - }, - async deleteEntry(orig) - { - if (!orig?.kalender_id) - return; - - await this.$api.call(ApiKalender.deleteEntry( - orig?.kalender_id - )).then(result => { - this.$refs.calendar.resetEventLoader(); - }); - }, - async openHistory(orig) - { - if (!orig?.kalender_id) - return; - await this.$api.call(ApiKalender.getHistory( - orig.kalender_id - )).then(result => { - this.historyEntries = result.data ?? []; - this.$refs.historyModel.show(); - }); - }, - async selectRaum(ort_kurzbz) { - const orig = this.raumModal; - await this.$api.call( - ApiKalender.updateKalenderEvent(orig.kalender_id, { - ort_kurzbz, - start_time: orig.von, - end_time: orig.bis - })).then(() => this.$refs.raumModal.hide()); - this.$refs.calendar.resetEventLoader(); - }, - setOrt: function(data) - { - this.ort_kurzbz = data.ort_kurzbz; - this.$refs.calendar.resetEventLoader(); - }, - onSelectVerbandAndClose(payload) { - this.onSelectVerband(payload); - bootstrap.Offcanvas.getOrCreateInstance(this.$refs.verbandMenu).hide(); - }, - onSelectVerband({link, name}) - { - let stg = null; - let semester = null; - this.show_stg = name - if (typeof link === 'number') - stg = link; - else if (typeof link === 'string') - { - [stg, semester] = link.split('/'); - } - this.stg = stg; - if (semester !== null) - this.semester = semester; - - - this.$refs.calendar.resetEventLoader(); - }, - setEmp: function(data) - { - const uid = data.uid; - const label = data.name; - if (!this.lecturers.some(l => l.uid === uid)) - { - this.lecturers.push({ - uid, - label, - showEvents: true, - overlays: { blocks: true, wishes: true }, - }); - } - - this.$refs.calendar.resetEventLoader(); - if (this.lastRange) - this.handleRange(this.lastRange); - }, - jumpToKw(kw) { - const num = parseInt(kw); - if (!num) - return; - - const date = luxon.DateTime.fromObject({ - weekYear: luxon.DateTime.now().setZone(this.config.timezone).weekYear, - weekNumber: num, - weekday: 1, - }, { zone: this.config.timezone }); - this.calendarDate = date.toISODate(); - }, - handleChangeDate(newDate) { - if (newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid) - this.calendarDate = newDate.toISODate(); - }, - handleChangeMode() { - console.log("handleChangeMode") - }, - toggleStatus(selected) { - if (!selected || selected.length === 0) { - this.visibleStatus = ['all']; - return; - } - const hasAll = selected.includes('all'); - const hadAll = this.visibleStatus.includes('all'); - - if (hasAll && !hadAll) - { - this.visibleStatus = ['all']; - return; - } - this.visibleStatus = selected.filter(k => k !== 'all'); - if (this.visibleStatus.length === 0) - this.visibleStatus = ['all']; - }, - searchfunction(params) { - return this.$api.call(ApiSearchbar.search(params)); - }, - getPromiseFunc(start, end) { - const hasRoom = !!this.ort_kurzbz; - const hasLektoren = this.lecturers.length > 0; - const hasStg = !!this.stg; - - const filter = {}; - - if (hasRoom) - filter.ort = this.ort_kurzbz; - if (hasStg) - filter.stg = this.stg; - if (hasLektoren) - filter.uid = this.lecturers.map(l => l.uid); - - if (this.previewRole === 'lektor') - return [this.$api.call(ApiKalender.getPlanLecturer(start.toISODate(), end.toISODate()))]; - - if (this.previewRole === 'student') - return [this.$api.call(ApiKalender.getPlanStudent(start.toISODate(), end.toISODate()))]; - - return [this.$api.call(ApiKalender.getPlan(filter, start.toISODate(), end.toISODate()))]; - }, - toDateTime(value, timezone){ - if (luxon.DateTime.isDateTime(value)) return value; - - if (value?.date?.isValid) - return value.date; - - if (typeof value === 'number') - return luxon.DateTime.fromMillis(value, { zone: timezone }); - - if (value instanceof Date) - return luxon.DateTime.fromJSDate(value, { zone: timezone }); - - if (typeof value === 'string') - return luxon.DateTime.fromISO(value, { zone: timezone }); - - return luxon.DateTime.invalid("invalid datetime"); - }, - getLastEndOfSameDay(startDT, ends) { - if (!ends?.length) return null; - - const dayKey = startDT.toISODate(); - let lastSameDay = null; - - for (const end of ends) { - const dt = luxon.DateTime.isDateTime(end) ? end : luxon.DateTime.fromISO(String(end), { zone: startDT.zoneName }); - - if (!dt.isValid) - continue; - - if (dt.toISODate() === dayKey) - lastSameDay = dt; - } - - return lastSameDay; - }, - clampEndToGrid(startDT, durationMin, ends) { - const calculatedEnd = startDT.plus({ minutes: durationMin }); - - const lastGridEndSameDay = this.getLastEndOfSameDay(startDT, ends); - - if (!lastGridEndSameDay) - return calculatedEnd; - - return calculatedEnd > lastGridEndSameDay ? lastGridEndSameDay : calculatedEnd; - }, - _parseDates(start, end) - { - const startDT = luxon.DateTime.fromISO(start); - const endDT = luxon.DateTime.fromISO(end); - - if (!startDT.isValid || !endDT.isValid) - { - alert("Ungültiges Datum"); - return null; - } - - return { - startDT, - endDT, - start_time: startDT.toFormat('yyyy-MM-dd HH:mm'), - end_time: endDT.toFormat('yyyy-MM-dd HH:mm'), - }; - }, - - _updateKalenderEvent(obj, startDT, endDT, start_time, end_time, onSuccess) - { - const origStart = luxon.DateTime.fromISO(obj.orig.isostart); - const origEnd = luxon.DateTime.fromISO(obj.orig.isoend); - - if (origStart.toMillis() === startDT.toMillis() && origEnd.toMillis() === endDT.toMillis()) - return; - - const updatedInfos = { - ort_kurzbz: this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, - start_time, - end_time, - }; - - return this.$api.call(ApiKalender.updateKalenderEvent(obj.orig.kalender_id, updatedInfos)) - .then(() => { - if (onSuccess) - onSuccess(); - }); - }, - - resizeHandler(payload) { - if (this.previewRole !== 'planer') //TODO (david) testzweck - return; - const { item, start, end } = payload; - const obj = item[0]; - if (!obj?.orig?.kalender_id) - return alert("Kein gültiges Kalender-Event zum Resizen"); - - const dates = this._parseDates(start, end); - - if (!dates) - return; - - return this._updateKalenderEvent(obj, dates.startDT, dates.endDT, dates.start_time, dates.end_time, () => { - this.$refs.calendar.resetEventLoader(); - }); - }, - - dropHandler(payload) { - if (this.previewRole !== 'planer') //TODO (david) testzweck - return; - const { item, start, end } = payload; - if (!item?.length) - return alert("Keine Daten gedroppt"); - - const obj = item[0]; - if (!obj?.type) - return alert("Unbekannter Drop-Typ"); - - const dates = this._parseDates(start, end); - if (!dates) return; - - const { startDT, endDT, start_time, end_time } = dates; - - if (obj.type === 'reservierung') - { - this.reservierungPending = true; - this.$refs.reservierung.show(start_time, end_time); - } - else if (obj.type === 'lehreinheit') - { - return this.$api.call( - ApiKalender.addKalenderEvent( - obj.orig.lehreinheit_id, - this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, - start_time, - end_time - ) - ).then(() => { - this.$refs.calendar.resetEventLoader(); - this.bcc.postMessage('dropped'); - }); - } - else if (obj.type === 'kalender') - { - return this._updateKalenderEvent(obj, startDT, endDT, start_time, end_time, () => - { - this.$refs.parking.unpark({ type: obj.type, id: obj.orig.kalender_id }); - this.$refs.calendar.resetEventLoader(); - this.bcc.postMessage('dropped'); - - }); - } - else - { - alert("Unbekannter Drop-Typ: " + obj.type); - } - }, - handleRange(range) { - if (!range?.start || !range?.end) - return; - - if (this.currentMode === 'week') - { - //Workaround because, updateRange is emitting 2 times - const startDay = range.start.startOf('day'); - const endDay = range.end.startOf('day'); - - const days = Math.round(endDay.diff(startDay, 'days').days) + 1; - if (days > 8) - return; - } - - this.lastRange = range; - - const key = `${range.start.toISODate()}_${range.end.toISODate()}_${this.currentMode}`; - - for (const lect of this.lecturers) - { - this.getOverlays(lect.uid, range, key); - } - - this.rebuildExtraBackgrounds(); - }, - - getOverlays(uid, range, rangeKey) - { - if (!this.overlayCache[uid]) - this.overlayCache[uid] = {}; - - let entry = this.overlayCache[uid][rangeKey]; - - if (entry?.loaded || entry?.loading) - return; - - entry = this.overlayCache[uid][rangeKey] = { - blocks: [], - wishes: [], - loading: true, - loaded: false - }; - - const promises = []; - const lect = this.lecturers.find(lecture => lecture.uid === uid); - - if (lect.overlays.wishes) - { - promises.push( - this.$api.call(ApiKalender.getLektorZeitwuensche(uid, range.start.toISODate(), range.end.toISODate())) - .then(result => { - entry.wishes = (result.data || []).map(zeitwunsch => ({ - class: `bg-lecturer-wish bg-uid-${uid} wish-w-${zeitwunsch.gewicht}`, - start: zeitwunsch.isostart, - end: zeitwunsch.isoend, - label: zeitwunsch.label - })); - }) - ); - } - - if (lect.overlays.blocks) - { - promises.push( - this.$api.call(ApiKalender.getLektorZeitsperren(uid, range.start.toISODate(), range.end.toISODate())) - .then(result => { - entry.blocks = (result.data || []).map(zeitsperre => ({ - class: `bg-lecturer-block bg-uid-${uid}`, - start: zeitsperre.isostart, - end: zeitsperre.isoend, - label: zeitsperre.label - })); - }) - ); - } - - Promise.allSettled(promises).then(() => { - entry.loading = false; - entry.loaded = true; - this.rebuildExtraBackgrounds(); - }); - }, - - rebuildExtraBackgrounds() { - if (!this.lastRange) - return; - - const key = `${this.lastRange.start.toISODate()}_` + `${this.lastRange.end.toISODate()}_` + `${this.currentMode}`; - let res = []; - - for (let lect of this.lecturers) - { - const entry = this.overlayCache[lect.uid]?.[key]; - if (!entry) - continue; - - if (lect.overlays.blocks) - res.push(...(entry.blocks || [])); - - if (lect.overlays.wishes) - res.push(...(entry.wishes || [])); - } - - this.extraBackgrounds = res; - }, - - removeLecturer(uid) - { - this.lecturers = this.lecturers.filter(lecture => lecture.uid !== uid); - delete this.overlayCache[uid]; - this.$refs.calendar.resetEventLoader(); - }, - clearOrt() { - this.ort_kurzbz = null; - this.$refs.calendar.resetEventLoader(); - }, - clearStg() { - this.stg = null; - this.show_stg = null; - this.$refs.calendar.resetEventLoader(); - }, - triggerSync() - { - this.$api.call(ApiKalender.sync()).then(this.$refs.calendar.resetEventLoader()) - } - }, - watch: { - lecturers: { - deep: true, - handler() { - this.rebuildExtraBackgrounds(); - } - } - }, - mounted() { - this.reservierungPending = false; - this.bcc = new BroadcastChannel('fhc-dnd'); - this.bcc.addEventListener('message', e => { - if (e.data === 'dropped' && !this.reservierungPending) - this.$refs.calendar.resetEventLoader(); - }); - }, - beforeUnmount() { - this.bcc.close(); - }, - async created() - { - await this.$api - .call(ApiRenderers.loadTempusRenderers()) - .then(res => res.data) - .then(data => { - for (let rendertype of Object.keys(data)) { - let modalTitle = null; - let modalContent = null; - let calendarEvent = null; - if (data[rendertype].modalTitle) - modalTitle = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].modalTitle))); - if (data[rendertype].modalContent) - modalContent = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].modalContent))); - if (data[rendertype].calendarEvent) - calendarEvent = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].calendarEvent))); - - if (data[rendertype].calendarEventStyles){ - var head = document.head; - if(!head.querySelector(`link[href="${data[rendertype].calendarEventStyles}"]`)){ - var link = document.createElement("link"); - link.type = "text/css"; - link.rel = "stylesheet"; - link.href = data[rendertype].calendarEventStyles; - head.appendChild(link); - } - } - - if(this.renderers === null) { - this.renderers = {}; - } - if (!this.renderers[rendertype]) { - this.renderers[rendertype] = {} - } - this.renderers[rendertype].modalTitle = modalTitle; - this.renderers[rendertype].modalContent = modalContent; - this.renderers[rendertype].calendarEvent = calendarEvent; - } - }); - - this.$api.call(ApiTempusConfig.getHeader()) - .then(res => { - this.visibleStatusArray = res.data.visible_status; - this.visibleStatus = ['all']; - }); - }, - template: ` + name: "Tempus", + components: { + CoreSearchbar, + VerticalSplit, + FhcCalendar, + FhcCoursepicker, + LectureSelection, + ParkingSlot, + AppConfig, + AppMenu, + NavLanguage, + BsModal, + StvVerband, + StvStudiensemester, + Multiselect: primevue.multiselect, + FormInput, + Reservierung, + }, + props: { + defaultSemester: String, + config: Object, + permissions: Object, + tempusRoot: String, + cisRoot: String, + activeAddons: String, // semicolon separated list of active addons + viewData: Object, + logoutUrl: String, + avatarUrl: String, + }, + directives: { + drop, + }, + provide() { + return { + cisRoot: this.cisRoot, + defaultSemester: this.defaultSemester, + currentSemester: this.defaultSemester, + renderers: Vue.computed(() => this.renderers), + appConfig: Vue.computed(() => this.appconfig), + contextMenuActions: Vue.computed(() => this.contextMenuActions), + }; + }, + data() { + return { + appconfig: {}, + configEndpoints: ApiTempusConfig, + endpoint: ApiStudiengangTree, + raumVorschlaege: [], + selected: [], + searchbaroptions: { + origin: "tempus", + cssclass: "position-relative", + calcheightonly: true, + types: [ + //"student", + "raum", + "mitarbeiter", + "mitarbeiter_ohne_zuordnung", + ], + actions: { + raum: { + defaultaction: { + type: "function", + action: this.setOrt, + }, + childactions: [], + }, + employee: { + defaultaction: { + type: "function", + action: (data) => { + this.setEmp(data); + }, + }, + childactions: [], + }, + }, + }, + lv_id: null, + events: null, + minimized: false, + currentlySelectedEvent: null, + //currentDay: new Date(), + studiensemesterKurzbz: this.defaultSemester, + lists: { + nations: [], + sprachen: [], + geschlechter: [], + }, + renderers: null, + ort_kurzbz: null, + view: "room", + parkedKeys: new Set(), + lecturers: [], + overlayCache: [], + extraBackgrounds: [], + lastRange: null, + stg: null, + show_stg: null, + semester: null, + studiensemester_kurzbz: null, + raumModal: { + show: false, + loading: false, + vorschlaege: [], + event: null, + }, + visibleStatusArray: {}, + visibleStatus: ["all"], + selectedStudiensemester: + this.studiensemester_kurzbz ?? this.defaultSemester, + calendarDate: luxon.DateTime.now() + .setZone(this.config.timezone) + .toISODate(), + historyEntries: [], + previewRole: "planer", + resourcesAssignmentModal: { + calendar: null, + availableResources: [], + filteredAvailableResources: [], + selectedAvailableResource: null, + assignedResources: [], + areFormButtonsDisplayed: false, + }, + }; + }, + computed: { + contextMenuActions() { + return { + lehreinheit: [ + { + label: "Raumauswahl", + icon: "fa-solid fa-door-open", + action: this.openRaumauswahl, + }, + { + label: "Ressourcen zuordnen", + icon: "fa-solid fa-list", + action: this.openResourcesAssignmentModal, + }, + { + label: "Freischalten für Voransicht", + icon: "fa-solid fa-chalkboard-user", + action: (orig) => + this.$api + .call(ApiKalender.syncToLecturer(orig.kalender_id)) + .then(() => this.$refs.calendar.resetEventLoader()), + }, + { + label: "Freischalten für Live", + icon: "fa-solid fa-user-graduate", + action: (orig) => + this.$api + .call(ApiKalender.syncToStudent(orig.kalender_id)) + .then(() => this.$refs.calendar.resetEventLoader()), + }, + { + label: "History", + icon: "fa-solid fa-clock-rotate-left", + action: this.openHistory, + }, + { + label: "Delete", + icon: "fa-solid fa-calendar-xmark", + action: this.deleteEntry, + }, + ], + reservierung: [ + { + label: "Delete", + icon: "fa-solid fa-calendar-xmark", + action: this.deleteEntry, + }, + ], + }; + }, + currentDay() { + return luxon.DateTime.now().setZone(this.config.timezone).toISODate(); + }, + currentMode() { + return "week"; + }, + visibleLecturerUids() { + if (!this.lecturers.length) return null; + return this.lecturers + .filter((lecture) => lecture.showEvents) + .map((lecture) => lecture.uid); + }, + visibleStatusOptions() { + return Object.entries(this.visibleStatusArray).map(([key, label]) => ({ + key, + label, + })); + }, + visibleStatusValue() { + if (this.visibleStatus.includes("all")) + return this.visibleStatusOptions.filter( + (visibleStatus) => visibleStatus.key === "all", + ); + return this.visibleStatus.map((status) => ({ + key: status, + label: this.visibleStatusArray[status], + })); + }, + dropdownParsedAvailableResources() { + return this.resourcesAssignmentModal.availableResources + .map((unit) => { + return { + label: unit.beschreibung, + value: unit.betriebsmittel_id, + data: unit, + }; + }) + .sort((a, b) => a.label?.localeCompare(b.label)); + }, + }, + methods: { + async openRaumauswahl(orig) { + if (!orig?.kalender_id) return; + this.raumModal = orig; + + await this.$api + .call(ApiKalender.getRaumvorschlag(orig.kalender_id)) + .then((result) => { + this.raumVorschlaege = result.data ?? []; + this.$refs.raumModal.show(); + }); + }, + async openResourcesAssignmentModal(orig) { + if (!orig?.kalender_id) return; + + this.resourcesAssignmentModal.calendar = orig; + this.resourcesAssignmentModal.availableResources = + await this.fetchSchedulableResourcesByCalender(orig.kalender_id); + this.resourcesAssignmentModal.filteredAvailableResources = [ + ...this.dropdownParsedAvailableResources, + ]; + + this.resourcesAssignmentModal.assignedResources = + await this.fetchAssignedResourcesByCalender(orig.kalender_id); + + this.$refs.resourcesAssignmentModal.show(); + }, + async deleteEntry(orig) { + if (!orig?.kalender_id) return; + + await this.$api + .call(ApiKalender.deleteEntry(orig?.kalender_id)) + .then((result) => { + this.$refs.calendar.resetEventLoader(); + }); + }, + async openHistory(orig) { + if (!orig?.kalender_id) return; + await this.$api + .call(ApiKalender.getHistory(orig.kalender_id)) + .then((result) => { + this.historyEntries = result.data ?? []; + this.$refs.historyModel.show(); + }); + }, + async selectRaum(ort_kurzbz) { + const orig = this.raumModal; + await this.$api + .call( + ApiKalender.updateKalenderEvent(orig.kalender_id, { + ort_kurzbz, + start_time: orig.von, + end_time: orig.bis, + }), + ) + .then(() => this.$refs.raumModal.hide()); + this.$refs.calendar.resetEventLoader(); + }, + setOrt: function (data) { + this.ort_kurzbz = data.ort_kurzbz; + this.$refs.calendar.resetEventLoader(); + }, + onSelectVerbandAndClose(payload) { + this.onSelectVerband(payload); + bootstrap.Offcanvas.getOrCreateInstance(this.$refs.verbandMenu).hide(); + }, + onSelectVerband({ link, name }) { + let stg = null; + let semester = null; + this.show_stg = name; + if (typeof link === "number") stg = link; + else if (typeof link === "string") { + [stg, semester] = link.split("/"); + } + this.stg = stg; + if (semester !== null) this.semester = semester; + + this.$refs.calendar.resetEventLoader(); + }, + setEmp: function (data) { + const uid = data.uid; + const label = data.name; + if (!this.lecturers.some((l) => l.uid === uid)) { + this.lecturers.push({ + uid, + label, + showEvents: true, + overlays: { blocks: true, wishes: true }, + }); + } + + this.$refs.calendar.resetEventLoader(); + if (this.lastRange) this.handleRange(this.lastRange); + }, + jumpToKw(kw) { + const num = parseInt(kw); + if (!num) return; + + const date = luxon.DateTime.fromObject( + { + weekYear: luxon.DateTime.now().setZone(this.config.timezone).weekYear, + weekNumber: num, + weekday: 1, + }, + { zone: this.config.timezone }, + ); + this.calendarDate = date.toISODate(); + }, + handleChangeDate(newDate) { + if (newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid) + this.calendarDate = newDate.toISODate(); + }, + handleChangeMode() { + console.log("handleChangeMode"); + }, + toggleStatus(selected) { + if (!selected || selected.length === 0) { + this.visibleStatus = ["all"]; + return; + } + const hasAll = selected.includes("all"); + const hadAll = this.visibleStatus.includes("all"); + + if (hasAll && !hadAll) { + this.visibleStatus = ["all"]; + return; + } + this.visibleStatus = selected.filter((k) => k !== "all"); + if (this.visibleStatus.length === 0) this.visibleStatus = ["all"]; + }, + searchfunction(params) { + return this.$api.call(ApiSearchbar.search(params)); + }, + getPromiseFunc(start, end) { + const hasRoom = !!this.ort_kurzbz; + const hasLektoren = this.lecturers.length > 0; + const hasStg = !!this.stg; + + const filter = {}; + + if (hasRoom) filter.ort = this.ort_kurzbz; + if (hasStg) filter.stg = this.stg; + if (hasLektoren) filter.uid = this.lecturers.map((l) => l.uid); + + if (this.previewRole === "lektor") + return [ + this.$api.call( + ApiKalender.getPlanLecturer(start.toISODate(), end.toISODate()), + ), + ]; + + if (this.previewRole === "student") + return [ + this.$api.call( + ApiKalender.getPlanStudent(start.toISODate(), end.toISODate()), + ), + ]; + + return [ + this.$api.call( + ApiKalender.getPlan(filter, start.toISODate(), end.toISODate()), + ), + ]; + }, + toDateTime(value, timezone) { + if (luxon.DateTime.isDateTime(value)) return value; + + if (value?.date?.isValid) return value.date; + + if (typeof value === "number") + return luxon.DateTime.fromMillis(value, { zone: timezone }); + + if (value instanceof Date) + return luxon.DateTime.fromJSDate(value, { zone: timezone }); + + if (typeof value === "string") + return luxon.DateTime.fromISO(value, { zone: timezone }); + + return luxon.DateTime.invalid("invalid datetime"); + }, + getLastEndOfSameDay(startDT, ends) { + if (!ends?.length) return null; + + const dayKey = startDT.toISODate(); + let lastSameDay = null; + + for (const end of ends) { + const dt = luxon.DateTime.isDateTime(end) + ? end + : luxon.DateTime.fromISO(String(end), { zone: startDT.zoneName }); + + if (!dt.isValid) continue; + + if (dt.toISODate() === dayKey) lastSameDay = dt; + } + + return lastSameDay; + }, + clampEndToGrid(startDT, durationMin, ends) { + const calculatedEnd = startDT.plus({ minutes: durationMin }); + + const lastGridEndSameDay = this.getLastEndOfSameDay(startDT, ends); + + if (!lastGridEndSameDay) return calculatedEnd; + + return calculatedEnd > lastGridEndSameDay + ? lastGridEndSameDay + : calculatedEnd; + }, + _parseDates(start, end) { + const startDT = luxon.DateTime.fromISO(start); + const endDT = luxon.DateTime.fromISO(end); + + if (!startDT.isValid || !endDT.isValid) { + alert("Ungültiges Datum"); + return null; + } + + return { + startDT, + endDT, + start_time: startDT.toFormat("yyyy-MM-dd HH:mm"), + end_time: endDT.toFormat("yyyy-MM-dd HH:mm"), + }; + }, + + _updateKalenderEvent(obj, startDT, endDT, start_time, end_time, onSuccess) { + const origStart = luxon.DateTime.fromISO(obj.orig.isostart); + const origEnd = luxon.DateTime.fromISO(obj.orig.isoend); + + if ( + origStart.toMillis() === startDT.toMillis() && + origEnd.toMillis() === endDT.toMillis() + ) + return; + + const updatedInfos = { + ort_kurzbz: this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, + start_time, + end_time, + }; + + return this.$api + .call( + ApiKalender.updateKalenderEvent(obj.orig.kalender_id, updatedInfos), + ) + .then(() => { + if (onSuccess) onSuccess(); + }); + }, + + resizeHandler(payload) { + if (this.previewRole !== "planer") + //TODO (david) testzweck + return; + const { item, start, end } = payload; + const obj = item[0]; + if (!obj?.orig?.kalender_id) + return alert("Kein gültiges Kalender-Event zum Resizen"); + + const dates = this._parseDates(start, end); + + if (!dates) return; + + return this._updateKalenderEvent( + obj, + dates.startDT, + dates.endDT, + dates.start_time, + dates.end_time, + () => { + this.$refs.calendar.resetEventLoader(); + }, + ); + }, + + dropHandler(payload) { + if (this.previewRole !== "planer") + //TODO (david) testzweck + return; + const { item, start, end } = payload; + if (!item?.length) return alert("Keine Daten gedroppt"); + + const obj = item[0]; + if (!obj?.type) return alert("Unbekannter Drop-Typ"); + + const dates = this._parseDates(start, end); + if (!dates) return; + + const { startDT, endDT, start_time, end_time } = dates; + + if (obj.type === "reservierung") { + this.reservierungPending = true; + this.$refs.reservierung.show(start_time, end_time); + } else if (obj.type === "lehreinheit") { + return this.$api + .call( + ApiKalender.addKalenderEvent( + obj.orig.lehreinheit_id, + this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, + start_time, + end_time, + ), + ) + .then(() => { + this.$refs.calendar.resetEventLoader(); + this.bcc.postMessage("dropped"); + }); + } else if (obj.type === "kalender") { + return this._updateKalenderEvent( + obj, + startDT, + endDT, + start_time, + end_time, + () => { + this.$refs.parking.unpark({ + type: obj.type, + id: obj.orig.kalender_id, + }); + this.$refs.calendar.resetEventLoader(); + this.bcc.postMessage("dropped"); + }, + ); + } else { + alert("Unbekannter Drop-Typ: " + obj.type); + } + }, + handleRange(range) { + if (!range?.start || !range?.end) return; + + if (this.currentMode === "week") { + //Workaround because, updateRange is emitting 2 times + const startDay = range.start.startOf("day"); + const endDay = range.end.startOf("day"); + + const days = Math.round(endDay.diff(startDay, "days").days) + 1; + if (days > 8) return; + } + + this.lastRange = range; + + const key = `${range.start.toISODate()}_${range.end.toISODate()}_${this.currentMode}`; + + for (const lect of this.lecturers) { + this.getOverlays(lect.uid, range, key); + } + + this.rebuildExtraBackgrounds(); + }, + + getOverlays(uid, range, rangeKey) { + if (!this.overlayCache[uid]) this.overlayCache[uid] = {}; + + let entry = this.overlayCache[uid][rangeKey]; + + if (entry?.loaded || entry?.loading) return; + + entry = this.overlayCache[uid][rangeKey] = { + blocks: [], + wishes: [], + loading: true, + loaded: false, + }; + + const promises = []; + const lect = this.lecturers.find((lecture) => lecture.uid === uid); + + if (lect.overlays.wishes) { + promises.push( + this.$api + .call( + ApiKalender.getLektorZeitwuensche( + uid, + range.start.toISODate(), + range.end.toISODate(), + ), + ) + .then((result) => { + entry.wishes = (result.data || []).map((zeitwunsch) => ({ + class: `bg-lecturer-wish bg-uid-${uid} wish-w-${zeitwunsch.gewicht}`, + start: zeitwunsch.isostart, + end: zeitwunsch.isoend, + label: zeitwunsch.label, + })); + }), + ); + } + + if (lect.overlays.blocks) { + promises.push( + this.$api + .call( + ApiKalender.getLektorZeitsperren( + uid, + range.start.toISODate(), + range.end.toISODate(), + ), + ) + .then((result) => { + entry.blocks = (result.data || []).map((zeitsperre) => ({ + class: `bg-lecturer-block bg-uid-${uid}`, + start: zeitsperre.isostart, + end: zeitsperre.isoend, + label: zeitsperre.label, + })); + }), + ); + } + + Promise.allSettled(promises).then(() => { + entry.loading = false; + entry.loaded = true; + this.rebuildExtraBackgrounds(); + }); + }, + + rebuildExtraBackgrounds() { + if (!this.lastRange) return; + + const key = + `${this.lastRange.start.toISODate()}_` + + `${this.lastRange.end.toISODate()}_` + + `${this.currentMode}`; + let res = []; + + for (let lect of this.lecturers) { + const entry = this.overlayCache[lect.uid]?.[key]; + if (!entry) continue; + + if (lect.overlays.blocks) res.push(...(entry.blocks || [])); + + if (lect.overlays.wishes) res.push(...(entry.wishes || [])); + } + + this.extraBackgrounds = res; + }, + + removeLecturer(uid) { + this.lecturers = this.lecturers.filter((lecture) => lecture.uid !== uid); + delete this.overlayCache[uid]; + this.$refs.calendar.resetEventLoader(); + }, + clearOrt() { + this.ort_kurzbz = null; + this.$refs.calendar.resetEventLoader(); + }, + clearStg() { + this.stg = null; + this.show_stg = null; + this.$refs.calendar.resetEventLoader(); + }, + triggerSync() { + this.$api + .call(ApiKalender.sync()) + .then(this.$refs.calendar.resetEventLoader()); + }, + async fetchAssignedResourcesByCalender(calenderId) { + let getAssignedResources = await this.$api.call( + ApiOperationalResourceToCalender.getAssignedResourcesByCalenderId( + calenderId, + ), + ); + if (getAssignedResources.meta.status === "success") { + return getAssignedResources.data + .filter((unit) => !!unit) + .map((unit) => { + return { + isNoteTextareaShown: unit.anmerkung && unit.anmerkung.trim() !== "", + ...unit, + }; + }).filter((unit) => !!unit) + } else { + this.$fhcAlert.alertError(this.$p.t("ui", "failed_assigned_resources_fetch_error_message")); + } + + return []; + }, + async fetchSchedulableResourcesByCalender(calendarID) { + let getSchedulableResourcesByCalendar = + await this.$api.call( + ApiOperationalResourceToCalender.getSchedulableResourcesByCalendar(calendarID), + ); + if ( + getSchedulableResourcesByCalendar.meta.status === + "success" + ) { + return getSchedulableResourcesByCalendar.data; + } else { + this.$fhcAlert.alertError(this.$p.t("ui", "failed_schedulable_resources_fetch_error_message")); + } + + return []; + }, + filterAvailableResources(event) { + this.resourcesAssignmentModal.filteredAvailableResources + const query = event.query.toLowerCase(); + if (!query) { + return (this.resourcesAssignmentModal.filteredAvailableResources = [ + ...this.dropdownParsedAvailableResources.filter((unit) => { + return !this.resourcesAssignmentModal.assignedResources.some( + (assigned) => assigned.betriebsmittel_id === unit.value, + ); + }), + ]); + } + + return (this.resourcesAssignmentModal.filteredAvailableResources = + this.dropdownParsedAvailableResources + .filter((unit) => { + return !this.resourcesAssignmentModal.assignedResources.some( + (assigned) => assigned.betriebsmittel_id === unit.value, + ); + }) + .filter((unit) => { + return unit.label.toLowerCase().includes(query); + })); + }, + toggleAssignedResourceNoteInput(resource) { + const index = this.resourcesAssignmentModal.assignedResources.findIndex( + (assigned) => assigned.betriebsmittel_id === resource.betriebsmittel_id, + ); + if (index !== -1) { + this.resourcesAssignmentModal.assignedResources[index].isNoteTextareaShown = + !this.resourcesAssignmentModal.assignedResources[index] + .isNoteTextareaShown; + } + + this.resourcesAssignmentModal.areFormButtonsDisplayed = true; + }, + removeAssignedResource(resource) { + this.resourcesAssignmentModal.assignedResources = + this.resourcesAssignmentModal.assignedResources.filter( + (assigned) => assigned.betriebsmittel_id !== resource.betriebsmittel_id, + ); + + this.resourcesAssignmentModal.areFormButtonsDisplayed = true; + }, + async refreshResourcesAssignmentModalData(calenderItem) { + this.resourcesAssignmentModal.availableResources = + await this.fetchSchedulableResourcesByCalender(calenderItem.kalender_id); + this.resourcesAssignmentModal.filteredAvailableResources = [ + ...this.dropdownParsedAvailableResources, + ]; + + this.resourcesAssignmentModal.assignedResources = await this.fetchAssignedResourcesByCalender(calenderItem.kalender_id); + this.resourcesAssignmentModal.selectedAvailableResource = null; + this.resourcesAssignmentModal.areFormButtonsDisplayed = false; + }, + async saveAssignedResourcesToCalendarItem(calenderItem, assignedResources) { + let getSchedulableResourcesByCalendar = + await this.$api.call( + ApiOperationalResourceToCalender.storeResourcesToCalendarRelationship(calenderItem.kalender_id, assignedResources) + ); + if ( + getSchedulableResourcesByCalendar.meta.status === + "success" + ) { + this.$fhcAlert.alertSuccess(this.$p.t("ui", "assigned_resources_save_success_message")); + await this.refreshResourcesAssignmentModalData(calenderItem); + } else { + this.$fhcAlert.alertError(this.$p.t("ui", "failed_assigned_resources_save_error_message")); + } + + this.$refs.calendar.resetEventLoader(); + this.$refs.resourcesAssignmentModal.hide(); + }, + closeResourcesAssignmentModal() { + this.resourcesAssignmentModal = { + availableResources: [], + filteredAvailableResources: [], + selectedAvailableResource: null, + assignedResources: [], + areFormButtonsDisplayed: false, + }; + }, + }, + watch: { + lecturers: { + deep: true, + handler() { + this.rebuildExtraBackgrounds(); + }, + }, + "resourcesAssignmentModal.selectedAvailableResource": function (newVal) { + if (!newVal) return; + + this.resourcesAssignmentModal.assignedResources.push({ + betriebsmittel_kalender_id: null, + betriebsmittel_id: newVal.data.betriebsmittel_id, + beschreibung: newVal.data.beschreibung, + anmerkung: "", + isNoteTextareaShown: false, + }); + + this.resourcesAssignmentModal.areFormButtonsDisplayed = true; + }, + }, + mounted() { + this.reservierungPending = false; + this.bcc = new BroadcastChannel("fhc-dnd"); + this.bcc.addEventListener("message", (e) => { + if (e.data === "dropped" && !this.reservierungPending) + this.$refs.calendar.resetEventLoader(); + }); + }, + beforeUnmount() { + this.bcc.close(); + }, + async created() { + await this.$api + .call(ApiRenderers.loadTempusRenderers()) + .then((res) => res.data) + .then((data) => { + for (let rendertype of Object.keys(data)) { + let modalTitle = null; + let modalContent = null; + let calendarEvent = null; + if (data[rendertype].modalTitle) + modalTitle = Vue.markRaw( + Vue.defineAsyncComponent( + () => import(data[rendertype].modalTitle), + ), + ); + if (data[rendertype].modalContent) + modalContent = Vue.markRaw( + Vue.defineAsyncComponent( + () => import(data[rendertype].modalContent), + ), + ); + if (data[rendertype].calendarEvent) + calendarEvent = Vue.markRaw( + Vue.defineAsyncComponent( + () => import(data[rendertype].calendarEvent), + ), + ); + + if (data[rendertype].calendarEventStyles) { + var head = document.head; + if ( + !head.querySelector( + `link[href="${data[rendertype].calendarEventStyles}"]`, + ) + ) { + var link = document.createElement("link"); + link.type = "text/css"; + link.rel = "stylesheet"; + link.href = data[rendertype].calendarEventStyles; + head.appendChild(link); + } + } + + if (this.renderers === null) { + this.renderers = {}; + } + if (!this.renderers[rendertype]) { + this.renderers[rendertype] = {}; + } + this.renderers[rendertype].modalTitle = modalTitle; + this.renderers[rendertype].modalContent = modalContent; + this.renderers[rendertype].calendarEvent = calendarEvent; + } + }); + + this.$api.call(ApiTempusConfig.getHeader()).then((res) => { + this.visibleStatusArray = res.data.visible_status; + this.visibleStatus = ["all"]; + }); + }, + template: /*html*/ `