Compare commits

...
Author SHA1 Message Date
adisposkofh e6db3bfa8a merged master into 2026-09-09 14:13:20 +02:00
adisposkofh 254ea1a2b5 removed redundant variable previously used for debugging 2026-09-09 14:09:30 +02:00
adisposkofh e8e9bc73ad reworked old-to-new coodle redirect to not rely on old coodle tables 2026-09-09 14:07:05 +02:00
Andreas Österreicher 59b4b2bac9 Merge branch 'feature-71639/StudVw_Noten_Renaming_Infopopup' 2026-09-09 12:40:42 +02:00
Andreas Österreicher 5f546fca2a Merge branch 'feature-82630/Infocenter_Verlinkung_StudVW' 2026-09-09 12:37:33 +02:00
Andreas Österreicher f74b885253 Abfrage von Verträgen korrigiert für Fälle wo der Monatsletzte gleichzeitig der Start des neuen Studiensemesters ist (zB 31.08.2026) damit die Abrechnung korrekt funktioniert (BFI) 2026-09-09 08:55:24 +02:00
Andreas Österreicher 3ab402adef Merge branch 'bugfix-82884_grades-export-group-names' 2026-09-08 14:43:17 +02:00
Manuela Thamer 52f24645fb update link StudVW 2026-09-08 14:23:59 +02:00
Manuela Thamer 4ac108174b rename title delete button 2026-09-08 08:29:08 +02:00
adisposkofh 6378b80d87 fixed issue where exported gradelist excel file formatted group names as numbers 2026-09-07 15:24:38 +02:00
Andreas Österreicher 658c8d6bd4 Schreibberechtigungsprüfung bei Anrechnungen angepasst damit diese korrekt angezeigt wird wenn oe_kurzb nicht gleich der Kurzbz des Studiegangs ist 2026-09-03 08:44:10 +02:00
Andreas Österreicher bf29faa5a7 Merge branch 'feature-82292/Cis4StudiumLVohneModulAnzeigen' 2026-09-02 15:20:28 +02:00
Andreas Österreicher 2ada167557 Merge branch 'bug-82614/StudVw_International_Incoming_Outgoing_GS_wird_nicht_geladen' 2026-09-02 14:56:27 +02:00
chfhtw cc9d1f983e bugfix: missing call to getStudiensemesterKurzbz 2026-09-01 10:39:02 +02:00
Johann Hoffmann 57c48f2c4a cis4 studium page dont filter away lehrveranstaltungen without a module and give them their own box in the ui; 2026-08-26 13:15:16 +02:00
adisposkofh 883fbaee76 removed use of retval from freebusy controller 2026-08-11 15:10:37 +02:00
adisposkofh e871e30a09 removed direct use of retval in all coodle-related models 2026-08-11 15:00:18 +02:00
adisposkofh 6f25a2b955 removed direct use of retval from all CoodleSurvey_model methods 2026-08-11 14:06:28 +02:00
adisposkofh f79c1dc090 removed direct use of retval from CoodleSurvey_model->getSurvey 2026-08-11 13:56:28 +02:00
adisposkofh ec23bffcbc minor coodle fix 2026-08-11 13:50:32 +02:00
adisposkofh 803afde542 reworked coodle-related email sending to avoid delays on user's end 2026-08-11 13:34:27 +02:00
adisposkofh efa04c0b68 Merge branch 'master' into feature/77174-nu-coodle 2026-08-06 13:40:39 +02:00
adisposkofh 6ae3fcd462 fixed 'new tab' links in coodle 2026-08-06 13:31:35 +02:00
adisposkofh 9be6708acc coodle data migration script 2026-08-05 17:22:15 +02:00
adisposkofh 8cd7241d81 modified coodle data migration 2026-08-03 16:35:59 +02:00
adisposkofh fa0422f577 modified coodle data migration 2026-08-03 16:34:38 +02:00
adisposkofh 0b07179986 coodle data migration 2026-08-03 16:06:29 +02:00
adisposkofh ddd54542ce redirecting old coodle urls to new coodle 2026-07-31 14:44:13 +02:00
adisposkofh 2af177ae87 reworked coodle external participants 2026-07-31 13:23:09 +02:00
adisposkofh 0c1abacebe fixed coodle bug, resetting cached freebusies when switching surveys 2026-07-31 11:33:02 +02:00
adisposkofh 30fda5b633 minor fix in coodle active surveys table config 2026-07-29 12:08:28 +02:00
adisposkofh e3377923ea minor mod to coodle participant search config 2026-07-28 10:35:19 +02:00
adisposkofh 0db5451c8c coodle, filtering existing participants from participant search results 2026-07-28 10:29:34 +02:00
adisposkofh 9c3651d97d Merge branch 'master' into feature/77174-nu-coodle 2026-07-27 15:52:35 +02:00
adisposkofh 74ee8fbb35 minor rename 2026-07-27 15:48:48 +02:00
adisposkofh f1ca657226 reimplemented coodle participant search with search library 2026-07-27 15:36:17 +02:00
adisposkofh 40a6acb61e coodle various minor fixes 2026-07-27 12:14:45 +02:00
adisposkofh bfc8da632a fixed use of conditionals with falsy empty arrays in coodle 2026-07-27 09:57:10 +02:00
adisposkofh a51d6ac4ea removed redundant coodle response meta 2026-07-27 09:51:44 +02:00
adisposkofh 92d191dc5d minor coodle fix 2026-07-27 09:46:25 +02:00
adisposkofh ae09eb712e removed obsolete import 2026-07-27 09:33:16 +02:00
adisposkofh d769e9db04 implemented pagination in coodle survey tables 2026-07-24 14:22:35 +02:00
adisposkofh 88304ff5a6 implemented localization in coodle 2026-07-24 14:04:56 +02:00
adisposkofh caa598e821 coodle external page appearance 2026-07-24 12:02:38 +02:00
adisposkofh 996e6b67f7 removed coodle ical redundant console logs 2026-07-24 10:13:01 +02:00
adisposkofh b8800af8fb implemented coodle external voting page 2026-07-23 16:06:47 +02:00
adisposkofh f0af0ba733 removed redundant route 2026-07-22 17:03:27 +02:00
adisposkofh 8ed61bd063 minor mod 2026-07-22 16:12:03 +02:00
adisposkofh cb985c3c25 implemented coodle ical comp and corresponding endpoint 2026-07-22 16:03:29 +02:00
adisposkofh d89ea5947e minor mod to coodle voting table appearance (grouping dates in top row) 2026-07-22 11:16:41 +02:00
adisposkofh e110a756c2 removed redundant coodle-related response meta 2026-07-22 11:14:34 +02:00
adisposkofh a4eb996f22 replaced coodle calendar timeslot card date and time inputs with vuedatepicker 2026-07-21 19:30:22 +02:00
adisposkofh ffa02a3c3d implemented basic logic for coodle external participants 2026-07-21 18:40:56 +02:00
adisposkofh 42a67187a4 implemented ui for adding external participants to coodle survey 2026-07-20 17:15:58 +02:00
adisposkofh dbbac6d6d3 added missing coodle localization 2026-07-20 15:44:53 +02:00
adisposkofh 913e623ba5 minor appearance rework of coodle room selection 2026-07-20 15:43:55 +02:00
adisposkofh 6021c824ef minor appearance rework of coodle room selection 2026-07-20 14:47:51 +02:00
adisposkofh 05374517f4 added filter for available rooms in coodle final selection 2026-07-20 14:39:37 +02:00
adisposkofh 48e35e4c05 added missing freebusy explainer localization 2026-07-20 14:21:24 +02:00
adisposkofh 54a57880a9 Merge branch 'master' into feature/77174-nu-coodle 2026-07-20 13:57:49 +02:00
adisposkofh 1dfc8b6a28 added organizer to coodle completed survey ics calendar invite 2026-07-20 13:47:14 +02:00
adisposkofh e7fde9bace using utc tz in coodle completed survey ics calendar invite 2026-07-20 13:32:04 +02:00
adisposkofh 686bfe3d22 coodle freebusy ics endpoint 2026-07-20 13:14:34 +02:00
adisposkofh 6eac071ef8 parsing internal freebusy (timetable and absences) for coodle 2026-07-17 16:07:10 +02:00
adisposkofh 0a9775cd83 minor mod to coodle participants formatting 2026-07-17 15:20:48 +02:00
adisposkofh 2cd5682536 parsing coodle freebusy calendars 2026-07-17 15:13:16 +02:00
adisposkofh c066b1979f displaying google-type freebusy in coodle calendar 2026-07-17 12:04:50 +02:00
adisposkofh cc505a88ab fixed issue with coodle survey timeslot/participant update 2026-07-16 15:17:19 +02:00
adisposkofh 51f9701322 removed redundant import from coodle survey api 2026-07-16 11:56:31 +02:00
adisposkofh d5c18412de fixed idempotency issue in freebusy form submission 2026-07-16 11:53:50 +02:00
adisposkofh abe0c88ebc fixed bug with querying coodle surveys 2026-07-16 11:17:36 +02:00
adisposkofh 16a07f6c17 disabled moveable columns in coodle survey tables 2026-07-16 11:16:06 +02:00
adisposkofh 8262412c3a added missing coodle phrases 2026-07-16 10:57:42 +02:00
adisposkofh 11e45cafa1 fixed coodle survey cancellation emails 2026-07-16 10:46:02 +02:00
adisposkofh 55a6c6b207 update coodle survey form validation fix 2026-07-16 10:27:28 +02:00
adisposkofh 39fe741f51 added coodle phrases 2026-07-15 23:48:03 +02:00
adisposkofh dd95f08a51 minor coodle fixes 2026-07-15 19:55:44 +02:00
adisposkofh 78c975df50 fixed disappearing coodle tab buttons 2026-07-15 11:14:23 +02:00
adisposkofh 1a53972aa0 minor mod to coodle/freebusy validations 2026-07-15 11:05:44 +02:00
adisposkofh 6dccb559c8 added form validations to coodle/freebusy 2026-07-15 10:53:26 +02:00
adisposkofh ceb2dbc99a when completing coodle survey, no room reservations and calendar invites if past timeslot selected 2026-07-14 17:18:03 +02:00
adisposkofh ce596eed82 minor mod to parsing fetched inactive coodle surveys 2026-07-14 16:43:40 +02:00
adisposkofh b2697c301c reworked inactive coodle survey 'canceled' and 'completed' columns into single 'ended' column 2026-07-14 16:39:40 +02:00
adisposkofh 7bc20a8562 option to switch coodle calendar modes on wider screens 2026-07-14 16:19:47 +02:00
adisposkofh 359e753d13 adapted coodle calendar for day view on mobile screens 2026-07-14 15:46:42 +02:00
adisposkofh 9d6a8c5a75 creating ics files for completed coodle surveys 2026-07-14 15:33:32 +02:00
adisposkofh 6463da1679 opening coodle survey based on route query param 2026-07-14 12:08:24 +02:00
adisposkofh 5ea8ddf3b5 added quick click option to add auth user to coodle survey participants 2026-07-14 12:00:46 +02:00
adisposkofh a8079b8e91 implemented various coodle-related emails 2026-07-14 11:29:00 +02:00
adisposkofh dae2c075de creating reservation for selected coodle timeslot 2026-07-13 18:32:00 +02:00
adisposkofh 59fc627c98 coodle voting minor fix 2026-07-13 11:23:04 +02:00
adisposkofh 385b5bdfac ported room reservation functionality from cis40_2026-05_ma_rc 2026-07-13 10:08:54 +02:00
adisposkofh 519da8433f implemented room selection in coodle completion form 2026-07-10 17:36:45 +02:00
adisposkofh fa94d9e03a updated coodle calendar to match latest renderers implementation 2026-07-10 16:55:56 +02:00
adisposkofh 74b916d0c5 implemented sending coodle voting reminders 2026-07-10 16:08:01 +02:00
adisposkofh c230990f25 merged master into 2026-07-10 15:50:02 +02:00
adisposkofh 85479a3c3a implemented coodle survey completion 2026-07-10 12:07:23 +02:00
adisposkofh de6ef0ce34 implemented coodle survey cancellation 2026-07-10 10:34:22 +02:00
adisposkofh f55adfb997 replaced use of browser pop-ups with fhc alert 2026-07-10 10:15:29 +02:00
adisposkofh 7af9ad8ead implemented coodle voting endpoint 2026-07-09 16:08:59 +02:00
adisposkofh 9ccf2f6e2e coodle voting table appearance when anon participants 2026-07-09 15:22:21 +02:00
adisposkofh 7659f21538 (in)active surveys tables 2026-07-09 11:33:57 +02:00
adisposkofh 036cabf871 coodle form minor styling fix 2026-07-08 10:23:12 +02:00
adisposkofh d5e0c2750a fixed incoming survey parsing 2026-07-08 10:20:37 +02:00
adisposkofh 4bec9a6023 creating coodle survey 2026-07-07 19:08:28 +02:00
adisposkofh a395a4383c initial work on displaying freebusy schedules in coodle 2026-07-07 10:51:11 +02:00
adisposkofh ab75fa9de6 formatting freebusy types 2026-07-06 12:58:19 +02:00
adisposkofh 534d963393 modified coodle/freebusy tables 2026-07-06 11:26:37 +02:00
adisposkofh 0f45712485 implemented coodle freebusy view 2026-06-26 19:09:34 +02:00
adisposkofh edfab4a6f6 can create coodle calendar events clicking through other events: 2026-06-26 11:04:10 +02:00
adisposkofh 6242c252b6 wrapping instead of truncating coodle basic info input labels 2026-06-25 15:31:56 +02:00
adisposkofh 098fdc991c minor fix 2026-06-25 15:27:31 +02:00
adisposkofh 80f46ae7e9 reworked coodle timeslot creation 2026-06-25 15:26:00 +02:00
adisposkofh a9ffdc2457 removed autoscroll 2026-06-25 11:36:17 +02:00
adisposkofh 3915b7418c minor fix 2026-06-25 10:59:35 +02:00
adisposkofh 158ba59d5d minor fix 2026-06-25 10:59:22 +02:00
adisposkofh f6dc4b7cbc added coodle privacy settings tab 2026-06-25 10:52:14 +02:00
adisposkofh bd004fe7f6 parsing coodle participant events 2026-06-25 10:51:16 +02:00
adisposkofh 77c162c95b implemented distinct coodle participant schedule colors 2026-06-24 17:32:41 +02:00
adisposkofh 338d01e9c6 preserving coodle timeslot id if removed, then reinstated in edit form 2026-06-24 16:34:48 +02:00
adisposkofh 600816dfca minor naming change 2026-06-24 16:25:22 +02:00
adisposkofh c473f98774 coodle calendar interaction with survey edit form 2026-06-24 16:24:34 +02:00
adisposkofh f63aa02c3a code formatting and minor fixes 2026-06-24 16:02:26 +02:00
adisposkofh a01764c5e6 added coodle event modal 2026-06-24 14:36:48 +02:00
adisposkofh 1bfd24313a implemented coodle survey calendar 2026-06-24 13:56:42 +02:00
adisposkofh d8a25dc0f3 debouncing and aborting coodle participants search 2026-06-22 14:58:18 +02:00
adisposkofh 1bd306cf74 implemented debounce helper 2026-06-22 14:46:12 +02:00
adisposkofh 3a27ddc410 reworked coodle voting table appearance 2026-06-22 14:30:26 +02:00
adisposkofh 3b0e62ff15 reworked coodle survey basic info appearance 2026-06-22 14:15:43 +02:00
adisposkofh 58882d99b7 minor fix 2026-06-22 14:12:25 +02:00
adisposkofh 82950bdd21 minor fix 2026-06-22 14:12:01 +02:00
adisposkofh 0064f93676 code formatting 2026-06-22 13:47:50 +02:00
adisposkofh 758a5a5253 reworked coodle voting table appearance 2026-06-22 13:47:10 +02:00
adisposkofh 60255775c1 reworked coodle voting table appearance 2026-06-22 13:42:13 +02:00
adisposkofh 6375ccde3b implemented api endpoint to search coodle survey participants 2026-06-22 13:06:46 +02:00
adisposkofh 359b37e184 coodle survey refactoring 2026-06-19 14:35:14 +02:00
adisposkofh 1e771694d7 coodle survey edit form 2026-06-18 18:22:45 +02:00
adisposkofh 10b09bce10 coodle ui 2026-06-17 19:21:06 +02:00
adisposkofh 1faf633034 added coodle view 2026-06-15 15:16:05 +02:00
72 changed files with 10713 additions and 1415 deletions
+20
View File
@@ -33,5 +33,25 @@ Events::on('loadRenderers', function ($renderers) {
);
});
Events::on('loadRenderers', function ($renderers) {
$fhc_core_renderers =& $renderers();
$fhc_core_renderers["coodle"] = array(
'calendarEvent' => absoluteJsImportUrl('public/js/components/Cis/Renderer/Coodle/calendarEvent.js'),
'modalTitle' => absoluteJsImportUrl('public/js/components/Cis/Renderer/Coodle/modalTitle.js'),
'modalContent' => absoluteJsImportUrl('public/js/components/Cis/Renderer/Coodle/modalContent.js'),
'calendarEventStyles' => APP_ROOT . 'public/css/Cis4/CoreCalendarEvents.css'
);
});
Events::on('loadRenderers', function ($renderers) {
$fhc_core_renderers =& $renderers();
$fhc_core_renderers["freeBusy"] = array(
'calendarEvent' => absoluteJsImportUrl('public/js/components/Cis/Renderer/FreeBusy/calendarEvent.js'),
'modalTitle' => absoluteJsImportUrl('public/js/components/Cis/Renderer/FreeBusy/modalTitle.js'),
'modalContent' => absoluteJsImportUrl('public/js/components/Cis/Renderer/FreeBusy/modalContent.js'),
'calendarEventStyles' => APP_ROOT . 'public/css/Cis4/CoreCalendarEvents.css'
);
});
+6
View File
@@ -68,6 +68,12 @@ $route['Cis/OtherLvPlan/.*'] = 'Cis/OtherLvPlan/index/$1';
//Route for LV Plan Stg/Semester/Verband/Gruppe
$route['Cis/StgOrgLvPlan/.*'] = 'Cis/StgOrgLvPlan/index/$1';
$route['Cis/CoodleExternal/(:any)/(:any)'] = 'Cis/CoodleExternal/index/$1/$2';
$route['CoodleFreeBusy/(:any)'] = 'api/frontend/v1/coodle/FreeBusy/getCoodleFreeBusy/$1';
$route['CoodleIcal/encrypted/(:any)'] = 'api/frontend/v1/coodle/CoodleSurvey/getCoodleIcalEncrypted/$1';
$route['CoodleIcal/(:any)'] = 'api/frontend/v1/coodle/CoodleSurvey/getCoodleIcal/$1';
$route['Abgabetool/Assistenz'] = 'Cis/Abgabetool/Assistenz';
$route['Abgabetool/Assistenz/(:any)'] = 'Cis/Abgabetool/Assistenz/$1';
$route['Abgabetool/Mitarbeiter'] = 'Cis/Abgabetool/Mitarbeiter';
+16
View File
@@ -872,3 +872,19 @@ $config['dms'] = [
)
)"
];
$config['group'] = [
'primarykey' => 'gruppe_kurzbz',
'table' => 'public.tbl_gruppe',
'searchfields' => [
'gruppe_kurzbz' => [
'comparison' => 'similar',
'field' => 'gruppe_kurzbz',
],
],
'resultfields' => [
"g.gruppe_kurzbz as name",
],
'resultjoin' => "
JOIN public.tbl_gruppe g USING (gruppe_kurzbz)"
];
@@ -0,0 +1,55 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
$CI =& get_instance();
$config['employee'] = $CI->config->item('employee', 'search');
unset($config['student']['searchfields']['email']);
unset($config['student']['searchfields']['tel']);
$config['employee']['resultfields'] = [
"m.mitarbeiter_uid AS uid",
"(p.vorname || ' ' || p.nachname) AS name",
];
$config['employee']['resultjoin'] = "
JOIN public.tbl_mitarbeiter m USING (mitarbeiter_uid)
JOIN public.tbl_benutzer b ON (b.uid = m.mitarbeiter_uid AND b.aktiv = true)
JOIN public.tbl_person p USING(person_id)
LEFT JOIN (
SELECT kontakt, standort_id
FROM public.tbl_kontakt
WHERE kontakttyp = 'telefon'
) k ON (k.standort_id = m.standort_id)";
$config['student'] = $CI->config->item('student', 'search');
unset($config['student']['searchfields']['email']);
unset($config['student']['searchfields']['tel']);
$config['student']['resultfields'] = [
"s.student_uid AS uid",
"(p.vorname || ' ' || p.nachname) AS name",
];
$config['student']['resultjoin'] = "
JOIN public.tbl_student s USING (student_uid)
JOIN public.tbl_benutzer b ON(b.uid = s.student_uid AND b.aktiv = true)
JOIN public.tbl_person p USING(person_id)";
$config['group'] = $CI->config->item('group', 'search');
$config['group']['resultfields'] = [
"g.gruppe_kurzbz as name",
"ARRAY(
SELECT bg.uid as uid
FROM public.tbl_benutzergruppe bg
WHERE bg.gruppe_kurzbz = g.gruppe_kurzbz
ORDER BY bg.uid
) AS user_uids",
"ARRAY(
SELECT p.vorname || ' ' || p.nachname AS name
FROM public.tbl_benutzergruppe bg
JOIN public.tbl_benutzer b ON(b.uid = bg.uid)
JOIN public.tbl_person p ON(p.person_id = b.person_id)
WHERE bg.gruppe_kurzbz = g.gruppe_kurzbz
ORDER BY bg.uid
) AS user_names",
];
+30
View File
@@ -0,0 +1,30 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
*
*/
class Coodle extends Auth_Controller
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct([
'index' => [self::PERM_LOGGED],
]);
}
// -----------------------------------------------------------------------------------------------------------------
// Public methods
/**
* @return void
*/
public function index()
{
$this->load->view('CisRouterView/CisRouterView.php', ["viewData" => [], 'route' => 'Coodle']);
}
}
@@ -0,0 +1,30 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
*
*/
class CoodleExternal extends Auth_Controller
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct([
'index' => [self::PERM_ANONYMOUS],
]);
}
// -----------------------------------------------------------------------------------------------------------------
// Public methods
/**
* @return void
*/
public function index()
{
$this->load->view('CisRouterView/CisRouterView.php', ['route' => 'CoodleExternal', 'isExternal' => true]);
}
}
@@ -16,7 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
if (!defined('BASEPATH')) exit('No direct script access allowed');
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class AuthInfo extends FHCAPI_Controller
{
@@ -37,6 +38,7 @@ class AuthInfo extends FHCAPI_Controller
$this->load->model('crm/Student_model', 'StudentModel');
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$this->load->model('person/Person_model', 'PersonModel');
$this->uid = getAuthUID();
$this->pid = getAuthPersonID();
@@ -54,15 +56,16 @@ class AuthInfo extends FHCAPI_Controller
*/
public function getAuthUID()
{
$this->terminateWithSuccess(['uid'=>$this->uid]);
$this->terminateWithSuccess(['uid' => $this->uid]);
}
public function getAuthInfo()
{
$data = (object) array(
'uid' => $this->uid,
'isMitarbeiter' => $this->isMitarbeiter,
'isStudent' => $this->isStudent
'uid' => $this->uid,
'isMitarbeiter' => $this->isMitarbeiter,
'isStudent' => $this->isStudent,
'name' => getData($this->PersonModel->getFullName($this->uid)),
);
$this->terminateWithSuccess($data);
}
@@ -47,7 +47,7 @@ class LvPlan extends FHCAPI_Controller
'fetchFerienEvents' => self::PERM_LOGGED,
'getStudiengaenge' => self::PERM_LOGGED,
'getLehrverband' => self::PERM_LOGGED,
'permissionOtherLvPlan' => self::PERM_LOGGED,
'permissionOtherLvPlan' => self::PERM_ANONYMOUS,
'compactibleEventTypes' => self::PERM_LOGGED,
'getLeEvents' => self::PERM_LOGGED,
'getLvEvents' => self::PERM_LOGGED,
@@ -62,6 +62,7 @@ class LvPlan extends FHCAPI_Controller
'dbExecuteUser' => 'RESTful API'
));
$this->load->library('PermissionLib');
$this->load->library('form_validation');
$this->load->library('PhrasesLib');
$this->loadPhrases(
@@ -437,7 +438,7 @@ class LvPlan extends FHCAPI_Controller
*/
public function permissionOtherLvPlan()
{
$this->terminateWithSuccess($this->permissionlib->isBerechtigt('basis/other_lv_plan'));
$this->terminateWithSuccess(getAuthUID() && $this->permissionlib->isBerechtigt('basis/other_lv_plan'));
}
/**
@@ -25,7 +25,7 @@ class RouteInfo extends FHCAPI_Controller
public function __construct()
{
parent::__construct([
'info' => self::PERM_LOGGED,
'info' => self::PERM_ANONYMOUS,
]);
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
@@ -33,6 +33,8 @@ class RouteInfo extends FHCAPI_Controller
public function info()
{
if (!getAuthUID()) $this->terminateWithSuccess(null);
$payload = json_decode($this->input->raw_input_stream);
if (isset($payload->app) && isset($payload->path) && $this->isValidApp($payload->app) && $this->isValidPath($payload->path))
@@ -167,7 +167,9 @@ class Studium extends FHCAPI_Controller
if($aktuelles_studienplan){
$lehrveranstaltungen = $this->computeStudienplanLehrveranstaltungen($aktuelles_studienplan->studienplan_id, $aktuelles_semester);
foreach($lehrveranstaltungen as $lehrv){
foreach($lehrv->lehrveranstaltungen as $lv){
// a lehrveranstaltung without a modul has no children and gets its lektoren directly
$lvs = isset($lehrv->lehrveranstaltungen) ? $lehrv->lehrveranstaltungen : [$lehrv];
foreach($lvs as $lv){
$lvLektoren =$this->computeLektorenFromLehrveranstaltung($lv->lehrveranstaltung_id,$aktuelles_semester, $aktuelles_studiengang->studiengang_kz, $aktuelles_studiensemester->studiensemester_kurzbz);
$lv->lektoren = $lvLektoren;
}
@@ -286,6 +288,7 @@ SELECT tbl_lehrveranstaltung.*,
$lehrveranstaltungen = $this->LehrveranstaltungModel->execReadOnlyQuery($query,[$studienplan_id, $semester]);
$lehrveranstaltungen = $this->getDataOrTerminateWithError($lehrveranstaltungen);
usort($lehrveranstaltungen, function($a, $b){
if($a->lehrtyp_kurzbz == "modul"){
return -1;
@@ -302,12 +305,17 @@ SELECT tbl_lehrveranstaltung.*,
}
else{
$parent =array_filter($carry, function($item)use($lehrv){
return $item->studienplan_lehrveranstaltung_id == $lehrv->studienplan_lehrveranstaltung_id_parent;
return isset($item->lehrveranstaltungen)
&& $item->studienplan_lehrveranstaltung_id == $lehrv->studienplan_lehrveranstaltung_id_parent;
});
$parent = current($parent);
if($parent){
$parent->lehrveranstaltungen[] = $lehrv;
}
else{
// lehrveranstaltung without a modul: keep it on the top level
array_push($carry, $lehrv);
}
}
return $carry;
}, []);
@@ -0,0 +1,232 @@
<?php
/**
* Copyright (C) 2024 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 <https://www.gnu.org/licenses/>.
*/
if (! defined('BASEPATH')) exit('No direct script access allowed');
class RoomPlan extends FHCAPI_Controller
{
/**
* Object initialization
*/
public function __construct()
{
parent::__construct([
'addRoomReservation' => self::PERM_LOGGED,
'deleteRoomReservation' => self::PERM_LOGGED,
'getRoomCreationInfo' => self::PERM_LOGGED,
'getGruppen' => self::PERM_LOGGED,
'getLektor' => self::PERM_LOGGED,
'getReservableMap' => self::PERM_LOGGED,
]);
$this->load->library('LogLib');
$this->loglib->setConfigs(array(
'classIndex' => 5,
'functionIndex' => 5,
'lineIndex' => 4,
'dbLogType' => 'API',
'dbExecuteUser' => 'RESTful API'
));
$this->load->library('form_validation');
$this->load->library('PermissionLib');
$this->load->library('StundenplanLib');
$this->loadPhrases(['ui']);
}
//------------------------------------------------------------------------------------------------------------------
// Public methods
public function addRoomReservation()
{
$this->form_validation->set_rules('selectedStart', "Start", "required");
$this->form_validation->set_rules('selectedEnd', "End", "required");
$this->form_validation->set_rules('title', "Title", "required|max_length[10]");
$this->form_validation->set_rules('beschreibung', "Beschreibung", "required|max_length[32]");
$this->form_validation->set_rules('ort_kurzbz', "Ort", "required|max_length[16]");
$this->form_validation->set_rules('studiengang', 'Studiengang', 'numeric');
$this->form_validation->set_rules('semester', 'Semester', 'integer|greater_than_equal_to[0]');
$this->form_validation->set_rules('verband', 'Verband', 'trim');
$this->form_validation->set_rules('gruppe', 'Gruppe', 'trim');
$this->form_validation->set_rules('spezialgruppe', 'Spezialgruppe', 'max_length[32]');
$this->form_validation->set_rules('lektoren', 'Lektoren');
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$start = $this->input->post('selectedStart');
$end = $this->input->post('selectedEnd');
$title = $this->input->post('title');
$beschreibung = $this->input->post('beschreibung');
$ort_kurzbz = $this->input->post('ort_kurzbz');
$studiengang_kz = $this->input->post('studiengang');
$semester = $this->input->post('semester');
$verband = $this->input->post('verband');
$gruppe = $this->input->post('gruppe');
$spezialgruppe = $this->input->post('spezialgruppe');
$lektoren = $this->input->post('lektoren');
$result = $this->stundenplanlib->addReservation($start, $end, $title, $beschreibung, $ort_kurzbz, $lektoren, $studiengang_kz, $semester, $verband, $gruppe, $spezialgruppe);
if (isError($result))
$this->terminateWithError($result);
$this->terminateWithSuccess($result);
}
public function deleteRoomReservation()
{
$reservierung_id = $this->input->post('reservierung_id');
$result = $this->stundenplanlib->deleteReservation($reservierung_id);
if (isError($result))
$this->terminateWithError($result);
$this->terminateWithSuccess($result);
}
public function getRoomCreationInfo()
{
$return_array = array('berechtigt' => false, 'studiengaenge' => []);
if (!$this->permissionlib->isBerechtigt('lehre/reservierung'))
$this->terminateWithSuccess($return_array);
$stg_berechtigungen = $this->permissionlib->getSTG_isEntitledFor('lehre/reservierung');
if (isEmptyArray($stg_berechtigungen))
$this->terminateWithSuccess($return_array);
$this->load->model('organisation/Studiengang_model', 'StudiengangModel');
$this->StudiengangModel->addSelect('studiengang_kz, UPPER(CONCAT(typ, kurzbz)) as kuerzel, kurzbzlang');
$this->StudiengangModel->addOrder('typ, kurzbz');
$this->StudiengangModel->db->where_in('studiengang_kz', $stg_berechtigungen);
$studiengaenge = $this->StudiengangModel->loadWhere(array('aktiv' => true));
if (isError($studiengaenge))
$this->terminateWithError($studiengaenge);
$return_array['studiengaenge'] = hasData($studiengaenge) ? getData($studiengaenge) : [];
$return_array['berechtigt'] = true;
$this->terminateWithSuccess($return_array);
}
public function getGruppen()
{
$query = $this->input->get('query');
if (is_null($query))
$this->terminateWithError($this->p->t('ui', 'ungueltigeParameter'), self::ERROR_TYPE_GENERAL);
$stg_berechtigungen = $this->permissionlib->getSTG_isEntitledFor('lehre/reservierung');
if (isEmptyArray($stg_berechtigungen))
$this->terminateWithSuccess([]);
$this->load->model('organisation/gruppe_model', 'GruppeModel');
$query_words = explode(' ', urldecode($query));
$this->GruppeModel->addOrder('gruppe_kurzbz');
$this->GruppeModel->db->group_start();
foreach ($query_words as $word)
{
$this->GruppeModel->db->group_start();
$this->GruppeModel->db->where('gruppe_kurzbz ILIKE', "%" . $word . "%");
$this->GruppeModel->db->or_where('bezeichnung ILIKE', "%" . $word . "%");
$this->GruppeModel->db->or_where('beschreibung ILIKE', "%" . $word . "%");
$this->GruppeModel->db->or_where('orgform_kurzbz ILIKE', "%" . $word . "%");
if (is_numeric($word))
{
$this->GruppeModel->db->or_where('studiengang_kz', $word);
}
$this->GruppeModel->db->group_end();
}
$this->GruppeModel->db->group_end();
$this->GruppeModel->db->where_in('studiengang_kz', $stg_berechtigungen);
$gruppen = $this->GruppeModel->loadWhere(array('sichtbar' => true, 'lehre' => true));
if (isError($gruppen))
$this->terminateWithError($gruppen);
$this->terminateWithSuccess(hasData($gruppen) ? getData($gruppen) : []);
}
public function getLektor()
{
$query = $this->input->get('query');
if (is_null($query))
$this->terminateWithError($this->p->t('ui', 'ungueltigeParameter'), self::ERROR_TYPE_GENERAL);
$stg_berechtigungen = $this->permissionlib->getSTG_isEntitledFor('lehre/reservierung');
if (isEmptyArray($stg_berechtigungen))
$this->terminateWithSuccess([]);
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$query_words = explode(' ', urldecode($query));
$this->MitarbeiterModel->addSelect('uid, person_id, vorname, nachname');
$this->MitarbeiterModel->addJoin('public.tbl_benutzer', 'uid = mitarbeiter_uid');
$this->MitarbeiterModel->addJoin('public.tbl_person', 'person_id');
$this->MitarbeiterModel->db->where('public.tbl_benutzer.aktiv', true);
$this->MitarbeiterModel->db->group_start();
foreach ($query_words as $word)
{
$this->MitarbeiterModel->db->group_start();
$this->MitarbeiterModel->db->where('tbl_person.vorname ILIKE', "%" . $word . "%");
$this->MitarbeiterModel->db->or_where('tbl_person.nachname ILIKE', "%" . $word . "%");
$this->MitarbeiterModel->db->or_where('uid ILIKE', "%" . $word . "%");
$this->MitarbeiterModel->db->group_end();
}
$this->MitarbeiterModel->db->group_end();
$this->MitarbeiterModel->addOrder('nachname');
$this->MitarbeiterModel->addOrder('vorname');
$mitarbeiter = $this->MitarbeiterModel->load();
if (isError($mitarbeiter))
$this->terminateWithError($mitarbeiter);
$this->terminateWithSuccess(hasData($mitarbeiter) ? getData($mitarbeiter) : []);
}
public function getReservableMap($ort_kurzbz = null)
{
$this->form_validation->set_rules('start_date', "StartDate", "required");
$this->form_validation->set_rules('end_date', "EndDate", "required");
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
// storing the post parameter in local variables
$start_date = $this->input->post('start_date', true);
$end_date = $this->input->post('end_date', true);
$result = $this->stundenplanlib->getReservableMap($ort_kurzbz, $start_date, $end_date);
$this->terminateWithSuccess(array('reservierbarMap' => hasData($result) ? getData($result) : []));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
<?php
/**
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class FreeBusy extends FHCAPI_Controller
{
/**
* Object initialization
*/
public function __construct()
{
parent::__construct([
'getFreeBusyTypes' => self::PERM_LOGGED,
'getFreeBusyEntries' => self::PERM_LOGGED,
'createFreeBusyEntry' => self::PERM_LOGGED,
'updateFreeBusyEntry' => self::PERM_LOGGED,
'deleteFreeBusyEntry' => self::PERM_LOGGED,
'getFreeBusySchedule' => self::PERM_LOGGED,
'getCoodleFreeBusy' => self::PERM_ANONYMOUS,
]);
$this->load->model('person/Freebusy_model', 'FreeBusyModel');
$this->load->model('person/Freebusytyp_model', 'FreeBusyTypeModel');
$this->load->library('FreeBusyLib');
$this->load->library('form_validation');
$this->load->model("ressource/CoodleSurvey_model", "CoodleSurveyModel");
$this->load->model("ressource/CoodleSurveyTimeslot_model", "CoodleSurveyTimeslotModel");
$this->load->model("ressource/CoodleSurveyParticipant_model", "CoodleSurveyParticipantModel");
$this->loadPhrases([
'coodle'
]);
}
//------------------------------------------------------------------------------------------------------------------
// Public methods
public function getFreeBusyTypes()
{
$freeBusyTypesResult = $this->FreeBusyTypeModel->getActiveFreeBusyTypes();
$freeBusyTypes = $this->getDataOrTerminateWithError($freeBusyTypesResult);
$freeBusyTypes = $this->formatDefaultUrlsInFreeBusyTypes($freeBusyTypes);
$this->terminateWithSuccess($freeBusyTypes);
}
public function getFreeBusyEntries()
{
$this->FreeBusyModel->addOrder("freebusy_id");
$freeBusyEntries = $this->FreeBusyModel->loadWhere(["uid" => getAuthUID()]);
$freeBusyEntries = $this->getDataOrTerminateWithError($freeBusyEntries);
$this->terminateWithSuccess($freeBusyEntries);
}
public function createFreeBusyEntry()
{
$description = $this->input->post("description");
$type = $this->input->post("type") ?? "Sonstiges";
$url = $this->input->post("url");
$isActive = $this->input->post("isActive");
$this->form_validation->set_data([
"url" => $url,
"description" => $description,
]);
$this->form_validation->set_rules("url", "URL", "required|max_length[255]");
$this->form_validation->set_rules("description", "Description", "max_length[255]");
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
if (!$this->isUrlValid($url)) {
$this->terminateWithError($this->p->t("coodle", "url_not_working"));
}
$this->FreeBusyModel->insert(
[
"uid" => getAuthUID(),
"freebusytyp_kurzbz" => $type,
"url" => $url,
"aktiv" => $isActive,
"bezeichnung" => $description,
]
);
$this->terminateWithSuccess();
}
public function updateFreeBusyEntry()
{
$id = $this->input->post("id");
$description = $this->input->post("description");
$type = $this->input->post("type") ?? "Sonstiges";
$url = $this->input->post("url");
$isActive = $this->input->post("isActive");
$this->form_validation->set_data([
"url" => $url,
"description" => $description,
]);
$this->form_validation->set_rules("url", "URL", "required|max_length[255]");
$this->form_validation->set_rules("description", "Description", "max_length[255]");
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
if (!$this->isUrlValid($url)) {
$this->terminateWithError($this->p->t("coodle", "url_not_working"));
}
$existingFreeBusyEntryResult = $this->FreeBusyModel->load($id);
$existingFreeBusyEntry = $this->getDataOrTerminateWithError($existingFreeBusyEntryResult);
$existingFreeBusyEntry = $existingFreeBusyEntry && count($existingFreeBusyEntry) ? $existingFreeBusyEntry[0] : null;
if (!$existingFreeBusyEntry) {
$this->terminateWithError("FreeBusy entry not found!");
} else if ($existingFreeBusyEntry->uid !== getAuthUID()) {
$this->terminateWithError("You are not authorized to modify this FreeBusy entry!");
}
$this->FreeBusyModel->update(
$id,
[
"uid" => getAuthUID(),
"freebusytyp_kurzbz" => $type,
"url" => $url,
"aktiv" => $isActive,
"bezeichnung" => $description,
]
);
$this->terminateWithSuccess();
}
public function deleteFreeBusyEntry()
{
$id = $this->input->post("id");
$existingFreeBusyEntryResult = $this->FreeBusyModel->load($id);
$existingFreeBusyEntry = $this->getDataOrTerminateWithError($existingFreeBusyEntryResult);
$existingFreeBusyEntry = $existingFreeBusyEntry && count($existingFreeBusyEntry) ? $existingFreeBusyEntry[0] : null;
if (!$existingFreeBusyEntry) {
$this->terminateWithError("FreeBusy entry not found!");
} else if ($existingFreeBusyEntry->uid !== getAuthUID()) {
$this->terminateWithError("You are not authorized to delete this FreeBusy entry!");
}
$this->FreeBusyModel->delete($id);
$this->terminateWithSuccess();
}
public function getFreeBusySchedule()
{
$uid = $this->input->post("uid");
$coodleSurveyIdToExclude = $this->input->post("coodleSurveyIdToExclude");
$freeBusyEntries = $this->FreeBusyModel->loadWhere(["uid" => $uid, "aktiv" => true]);
$freeBusyEntries = $this->getDataOrTerminateWithError($freeBusyEntries);
$cumulativeFreeBusyEvents = $this->freebusylib->getDefaultInternalFreeBusy($uid);
foreach ($freeBusyEntries as $freeBusyEntry) {
// todo: following workaround is to use new coodle freebusy endpoint, remove once actual redirect is set up
// keep query param with excluded survey id, only remove url replacement
$freeBusyUrl = $freeBusyEntry->url;
if ($freeBusyEntry->freebusytyp_kurzbz === "Coodle") {
$freeBusyUrl = APP_ROOT . "cis.php/CoodleFreeBusy/" . $uid;
if ($coodleSurveyIdToExclude) {
$freeBusyUrl .= "?excludeSurvey=" . $coodleSurveyIdToExclude;
}
}
$freeBusyEvents = $this->freebusylib->getFreeBusy($freeBusyUrl);
$cumulativeFreeBusyEvents = array_merge(
$cumulativeFreeBusyEvents,
$freeBusyEvents
);
}
$this->terminateWithSuccess($cumulativeFreeBusyEvents);
}
public function getCoodleFreeBusy($uid)
{
$participantEntries = $this->CoodleSurveyParticipantModel->getParticipantEntriesByUid($uid);
$surveys = [];
if (count($participantEntries)) {
$surveyIds = array_map(
function ($participantEntry) {
return $participantEntry->survey_id;
},
$participantEntries
);
$surveys = $this->CoodleSurveyModel->getSurveys($surveyIds);
}
$surveyIdToBeExcluded = $this->input->get("excludeSurvey");
if ($surveyIdToBeExcluded) {
$surveyIdToBeExcluded = intval($surveyIdToBeExcluded);
$surveys = array_filter(
$surveys,
function ($survey) use ($surveyIdToBeExcluded) {
return $survey->id !== $surveyIdToBeExcluded;
}
);
}
$activeSurveys = array_filter(
$surveys,
function ($survey) {
return !$survey->completed_at && !$survey->canceled_at;
}
);
$timeslots = [];
if (count($activeSurveys)) {
$activeSurveyIds = array_map(
function ($activeSurvey) {
return $activeSurvey->id;
},
$activeSurveys
);
$timeslots = $this->CoodleSurveyTimeslotModel->getTimeslotsForMultipleSurveys($activeSurveyIds);
}
$formattedTimeslots = [];
if (count($timeslots)) {
$localTimezone = new DateTimeZone("Europe/Vienna");
$utcTimezone = new DateTimeZone("UTC");
foreach ($activeSurveys as $activeSurvey) {
$correspondingTimeslots = array_filter(
$timeslots,
function ($timeslot) use ($activeSurvey) {
return $timeslot->survey_id === $activeSurvey->id;
}
);
$formattedCorrespondingTimeslots = array_map(
function ($timeslot) use ($activeSurvey, $utcTimezone, $localTimezone) {
$startTime = new DateTime($timeslot->starts_at, $localTimezone);
$endTime = new DateTime($timeslot->starts_at, $localTimezone);
$endTime->modify("+$activeSurvey->timeslot_duration minutes");
$startTime->setTimezone($utcTimezone);
$endTime->setTimezone($utcTimezone);
$formattedStartTime = $startTime->format("Ymd\THis\Z");
$formattedEndTime = $endTime->format("Ymd\THis\Z");
return [
"start" => $formattedStartTime,
"end" => $formattedEndTime,
];
},
$correspondingTimeslots
);
$formattedTimeslots = array_merge($formattedTimeslots, $formattedCorrespondingTimeslots);
}
}
$coodleFreeBusyIcsFilePath = tempnam(sys_get_temp_dir(), "coodle_freebusy_");
$coodleFreeBusyIcsFile = fopen($coodleFreeBusyIcsFilePath, "w");
fwrite($coodleFreeBusyIcsFile, "BEGIN:VCALENDAR" . PHP_EOL);
fwrite($coodleFreeBusyIcsFile, "VERSION:2.0" . PHP_EOL);
fwrite($coodleFreeBusyIcsFile, "PRDODID:" . CAMPUS_NAME . PHP_EOL);
fwrite($coodleFreeBusyIcsFile, "ATTENDEE:mailto:" . $uid . "@" . DOMAIN . PHP_EOL);
fwrite($coodleFreeBusyIcsFile, "BEGIN:VFREEBUSY" . PHP_EOL);
foreach ($formattedTimeslots as $timeslot) {
fwrite($coodleFreeBusyIcsFile, "FREEBUSY;FBTYPE=BUSY:" . $timeslot["start"] . "/" . $timeslot["end"] . PHP_EOL);
}
fwrite($coodleFreeBusyIcsFile, "END:VFREEBUSY" . PHP_EOL);
fwrite($coodleFreeBusyIcsFile, "END:VCALENDAR");
$this->terminateWithFileOutput("text/calendar", file_get_contents($coodleFreeBusyIcsFilePath), "coodle_freebusy_$uid.ics");
}
// -----------------------------------------------------------------------------------------------------------------
// Private methods
private function isUrlValid($url)
{
return !!@fopen($url, "r");
}
private function formatDefaultUrlsInFreeBusyTypes($freeBusyTypes)
{
$uid = getAuthUID();
return array_map(function ($freeBusyType) use ($uid) {
if (!$freeBusyType->url_vorlage || !strlen($freeBusyType->url_vorlage)) {
$freeBusyType->url_default = null;
unset($freeBusyType->url_vorlage);
return $freeBusyType;
}
$freeBusyType->url_default = str_replace('$uid', $uid, $freeBusyType->url_vorlage);
unset($freeBusyType->url_vorlage);
return $freeBusyType;
}, $freeBusyTypes);
}
}
@@ -108,6 +108,7 @@ class Students extends FHCAPI_Controller
'studiensemester_kurzbz' => $studiensemester_kurzbz
]);
$studiensemester_kurzbz = $this->getStudiensemesterKurzbz($studiensemester_kurzbz);
$this->studentlistlib->addJoin(
"(
@@ -153,7 +154,8 @@ class Students extends FHCAPI_Controller
$this->addMeta('ci_params', [
'studiensemester_kurzbz' => $studiensemester_kurzbz
]);
$studiensemester_kurzbz = $this->getStudiensemesterKurzbz($studiensemester_kurzbz);
$this->studentlistlib->addJoin(
"(
@@ -213,6 +215,7 @@ class Students extends FHCAPI_Controller
'studiensemester_kurzbz' => $studiensemester_kurzbz
]);
$studiensemester_kurzbz = $this->getStudiensemesterKurzbz($studiensemester_kurzbz);
$this->studentlistlib->addJoin(
"(
+15 -1
View File
@@ -33,6 +33,7 @@ if (! defined('BASEPATH')) exit('No direct script access allowed');
* @param string $headerImg Filename of the specific Sancho header image.
* @param string $cc Sets CC of mail.
* @param string $bcc Sets BCC of mail.
* @param array $attachments array of arrays with 'filePath' and (optional) 'altName' keys
* @return void
*/
function sendSanchoMail(
@@ -44,7 +45,8 @@ function sendSanchoMail(
$footerImg = '',
$from = null,
$cc = null,
$bcc = null
$bcc = null,
$attachments = []
)
{
$ci =& get_instance();
@@ -116,6 +118,18 @@ function sendSanchoMail(
$ci->email->attach($sanchoFooter_img, 'inline');
$cid_header = $ci->email->attachment_cid($sanchoHeader_img); // sets unique content id for embedding
$cid_footer = $ci->email->attachment_cid($sanchoFooter_img); // sets unique content id for embedding
// attach files
if (count($attachments)) {
foreach ($attachments as $attachment) {
if (isset($attachment["altName"])) {
$ci->email->attach($attachment["filePath"], 'attachment', $attachment["altName"]);
} else {
$ci->email->attach($attachment["filePath"], 'attachment');
}
}
}
}
// Set specific mail content into specific content template
+14 -1
View File
@@ -25,4 +25,17 @@ class CryptLib
return $cipher->encrypt($value);
}
}
public static function RIJNDAEL_256_ECB_DECRYPT($value, $key, $paddingDisabled = false)
{
if (isEmptyString($value) || isEmptyString($key)) return null;
$cipher = new Rijndael(Rijndael::MODE_ECB);
$cipher->setBlockLength(256);
$cipher->setKey($key);
if ($paddingDisabled === true) $cipher->disablePadding();
return $cipher->decrypt($value);
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
/**
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
if (!defined('BASEPATH'))
exit('No direct script access allowed');
// require_once('../../include/ical.class.php');
class FreeBusyLib
{
private $_ci; // Code igniter instance
public function __construct()
{
$this->_ci =& get_instance();
$this->_ci->load->model("ressource/Zeitsperre_model", "ZeitsperreModel");
}
//------------------------------------------------------------------------------------------------------------------
// Public methods
public function getFreeBusy($url)
{
$fileStream = @fopen($url, "r");
if (!$fileStream)
return [];
$busyTimeslots = [];
$fileContent = file_get_contents($url);
if (mb_strpos($fileContent, "BEGIN:VFREEBUSY") !== false) {
$busyTimeslots = $this->parseFreeBusyFormat($fileContent);
} else {
$busyTimeslots = $this->parseConventionalFormat($fileContent);
}
return $busyTimeslots;
}
public function getDefaultInternalFreeBusy($uid)
{
$events = [];
$this->_ci->load->library('StundenplanLib');
$startDate = new DateTime();
$startDate = $startDate->format("Y-m-d");
$endDate = new DateTime();
$endDate->modify("+1 year");
$endDate = $endDate->format("Y-m-d");
$userEvents = getData($this->_ci->stundenplanlib->getEventsUser($startDate, $endDate, $uid));
if ($userEvents && count($userEvents)) {
$userEvents = array_map(
function ($event) {
return [
"start" => $event->datum . " " . $event->beginn,
"end" => $event->datum . " " . $event->ende,
];
},
$userEvents
);
$events = array_merge($events, $userEvents);
}
$userAbsences = getData($this->_ci->ZeitsperreModel->getZeitsperre($uid));
if ($userAbsences && count($userAbsences)) {
$today = new DateTime();
$today = $today->format("Y-m-d");
$userAbsences = array_filter(
$userAbsences,
function ($absence) use ($today) {
return $absence->bisdatum >= $today;
}
);
$userAbsences = array_map(
function ($absence) {
return [
"start" => $absence->vondatum . " 00:00:00",
"end" => $absence->bisdatum . " 23:59:00",
];
},
$userAbsences
);
$events = array_merge($events, $userAbsences);
}
return $events;
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
private function parseConventionalFormat($fileContent)
{
$fileContentRows = explode("\n", $fileContent);
$fileContentRows = array_map(function ($row) {
return trim($row);
}, $fileContentRows);
$busyTimeslots = [];
$isParsingEvent = false;
$parsedEvent = ["start" => null, "end" => null];
$currentDateTime = new DateTime();
$currentDateTime = $currentDateTime->format("Y-m-d H:i:s");
foreach ($fileContentRows as $row) {
if (mb_strpos($row, "BEGIN:VEVENT") !== false) {
$isParsingEvent = true;
} else if (mb_strpos($row, "END:VEVENT") !== false) {
if (
$isParsingEvent &&
$parsedEvent["start"] &&
$parsedEvent["end"] &&
$parsedEvent["end"] > $currentDateTime
) {
$busyTimeslots[] = $parsedEvent;
}
$isParsingEvent = false;
$parsedEvent["start"] = null;
$parsedEvent["end"] = null;
} else if (mb_strpos($row, "DTSTART;VALUE=DATE") !== false) {
$startDateString = explode(":", $row)[1];
$start = substr($startDateString, 0, 4) . "-" . substr($startDateString, 4, 2) . "-" . substr($startDateString, 6, 2) . " 00:00:00";
$parsedEvent["start"] = $start;
} else if (mb_strpos($row, "DTSTART") !== false) {
$timestamp = explode(":", $row)[1];
if (mb_substr($timestamp, -1) === "Z") {
$timestamp = $this->convertTimestampToLocalTimezone($timestamp);
} else if (mb_strpos($row, "TZID=") !== false) {
$remainingString = explode("TZID=", $row)[1];
$remainingString = explode(":", $remainingString)[0];
$timezoneIdentifier = explode(";", $remainingString)[0];
$timestamp = $this->convertTimestampToLocalTimezone($timestamp, $timezoneIdentifier);
}
$startDate = substr($timestamp, 0, 4) . "-" . substr($timestamp, 4, 2) . "-" . substr($timestamp, 6, 2);
$startTime = substr($timestamp, 9, 2) . ":" . substr($timestamp, 11, 2) . ":" . substr($timestamp, 13, 2);
$parsedEvent["start"] = $startDate . " " . $startTime;
} else if (mb_strpos($row, "DTEND;VALUE=DATE") !== false) {
$endDateString = explode(":", $row)[1];
$end = substr($endDateString, 0, 4) . "-" . substr($endDateString, 4, 2) . "-" . substr($endDateString, 6, 2) . " 00:00:00";
$parsedEvent["end"] = $end;
} else if (mb_strpos($row, "DTEND") !== false) {
$timestamp = explode(":", $row)[1];
if (mb_substr($timestamp, -1) === "Z") {
$timestamp = $this->convertTimestampToLocalTimezone($timestamp);
} else if (mb_strpos($row, "TZID=") !== false) {
$remainingString = explode("TZID=", $row)[1];
$remainingString = explode(":", $remainingString)[0];
$timezoneIdentifier = explode(";", $remainingString)[0];
$timestamp = $this->convertTimestampToLocalTimezone($timestamp, $timezoneIdentifier);
}
$endDate = substr($timestamp, 0, 4) . "-" . substr($timestamp, 4, 2) . "-" . substr($timestamp, 6, 2);
$endTime = substr($timestamp, 9, 2) . ":" . substr($timestamp, 11, 2) . ":" . substr($timestamp, 13, 2);
$parsedEvent["end"] = $endDate . " " . $endTime;
}
}
return $busyTimeslots;
}
private function parseFreeBusyFormat($fileContent)
{
$fileContentRows = explode("\n", $fileContent);
$fileContentRows = array_map(function ($row) {
return trim($row);
}, $fileContentRows);
$busyTimeslots = [];
$currentDateTime = new DateTime();
$currentDateTime = $currentDateTime->format("Y-m-d H:i:s");
foreach ($fileContentRows as $row) {
if (
mb_strpos($row, "VFREEBUSY") !== false ||
mb_strpos($row, "FREEBUSY") === false ||
mb_strpos($row, "FBTYPE=FREE") !== false
)
continue;
$timestamps = explode(":", $row)[1];
$timestamps = explode(",", $timestamps);
foreach ($timestamps as $timestamp) {
$startEndTimestamps = explode("/", $timestamp);
$startTimestamp = $startEndTimestamps[0];
$endTimestamp = $startEndTimestamps[1];
if (mb_substr($startTimestamp, -1) === "Z") {
$startTimestamp = $this->convertTimestampToLocalTimezone($startTimestamp);
}
if (mb_substr($endTimestamp, -1) === "Z") {
$endTimestamp = $this->convertTimestampToLocalTimezone($endTimestamp);
}
$startDate = substr($startTimestamp, 0, 4) . "-" . substr($startTimestamp, 4, 2) . "-" . substr($startTimestamp, 6, 2);
$startTime = substr($startTimestamp, 9, 2) . ":" . substr($startTimestamp, 11, 2) . ":" . substr($startTimestamp, 13, 2);
$start = $startDate . " " . $startTime;
$endDate = substr($endTimestamp, 0, 4) . "-" . substr($endTimestamp, 4, 2) . "-" . substr($endTimestamp, 6, 2);
$endTime = substr($endTimestamp, 9, 2) . ":" . substr($endTimestamp, 11, 2) . ":" . substr($endTimestamp, 13, 2);
$end = $endDate . " " . $endTime;
if ($end < $currentDateTime)
continue;
$busyTimeslots[] = ["start" => $start, "end" => $end];
}
}
return $busyTimeslots;
}
private function convertTimestampToLocalTimezone($timestamp, $originalTimezoneIdentifier = "UTC")
{
$originalTimezone = new DateTimeZone($originalTimezoneIdentifier);
$localTimezone = new DateTimeZone("Europe/Vienna");
$date = new DateTime($timestamp, $originalTimezone);
$date->setTimezone($localTimezone);
return $date->format("Ymd\THis");
}
}
+240
View File
@@ -116,6 +116,7 @@ class StundenplanLib
return $stundenplan_data;
$stundenplan_data = getData($stundenplan_data) ?? [];
$this->_ci->addMeta("stundenplanData", $stundenplan_data);
$function_error = $this->expandObjectInformation($stundenplan_data);
if ($function_error)
return $function_error;
@@ -391,6 +392,39 @@ class StundenplanLib
$item->gruppe = $gruppe_obj_array;
$item->lektor = $lektor_obj_array;
$this->_ci->load->library('PermissionLib');
$berechtigt_begrenzt = $this->_ci->permissionlib->isBerechtigt('lehre/reservierung:begrenzt', 'sui');
$now = time();
$res_lektor_start = $this->jump_day($now, RES_TAGE_LEKTOR_MIN - 1);
$res_lektor_ende = mktime(0, 0, 0, date('m', $now), date('d', $now) + RES_TAGE_LEKTOR_BIS, date('Y', $now));
$start_date = is_numeric($item->beginn) ? $item->beginn : strtotime($item->beginn);
if (!date('w', $start_date)) {
$start_date = $this->jump_day($start_date, 1);
}
$start_date_str = date('Y-m-d', $start_date);
$res_lektor_start_str = date('Y-m-d', $res_lektor_start);
$res_lektor_ende_str = date('Y-m-d', $res_lektor_ende);
$show_delete = (
(
$berechtigt_begrenzt &&
(
(isset($item->insertvon) && $item->insertvon == getAuthUID()) ||
(isset($item->uids) && in_array(getAuthUID(), $item->uids))
)
) &&
$start_date_str >= $res_lektor_start_str &&
$start_date_str <= $res_lektor_ende_str
);
if ($show_delete)
$item->deletable = true;
else
$item->deletable = false;
}
}
@@ -486,6 +520,212 @@ class StundenplanLib
return success($stundenplan_data);
}
public function addReservation($start, $end, $title, $beschreibung, $ort_kurzbz, $lektoren = null, $studiengang = null, $semester = null, $verband = null, $gruppe = null, $spezialgruppe = null)
{
$this->_ci =& get_instance();
$this->_ci->load->model('ressource/Stunde_model', 'StundeModel');
$this->_ci->load->model('ressource/Reservierung_model', 'ReservierungModel');
$this->_ci->load->model('ressource/stundenplandev_model', 'StundenplandevModel');
$this->_ci->load->model('ressource/stundenplan_model', 'StundenplanModel');
$this->_ci->load->library('PermissionLib');
$startTime = new DateTime($start);
$endTime = new DateTime($end);
$stunden = $this->_ci->StundeModel->loadWhere(array(
'beginn <' => $endTime->format('H:i:s'),
'ende >' => $startTime->format('H:i:s')
));
if (!hasData($stunden))
{
return error("Keine Stunden vorhanden");
}
$stunden = array_column(getData($stunden), 'stunde');
$this->_ci->StundenplandevModel->db->select('1');
$this->_ci->StundenplandevModel->db->where('datum', $startTime->format('Y-m-d'));
$this->_ci->StundenplandevModel->db->where('ort_kurzbz', $ort_kurzbz);
$this->_ci->StundenplandevModel->db->where_in('stunde', $stunden);
$stundenplandev_belegung = $this->_ci->StundenplandevModel->load();
$this->_ci->StundenplanModel->db->select('1');
$this->_ci->StundenplanModel->db->where('ort_kurzbz', $ort_kurzbz);
$this->_ci->StundenplanModel->db->where('datum', $startTime->format('Y-m-d'));
$this->_ci->StundenplanModel->db->where_in('stunde', $stunden);
$stundenplan_belegung = $this->_ci->StundenplanModel->load();
if ((hasData($stundenplandev_belegung) || hasData($stundenplan_belegung))
&& !$this->_ci->permissionlib->isBerechtigt('lehre/reservierungAdvanced'))
return error ('lvplan/bereitsReserviert');
$this->_ci->ReservierungModel->addSelect('stunde');
$reservation_hours = $this->_ci->ReservierungModel->loadWhere(array('datum' => $startTime->format('Y-m-d'), 'ort_kurzbz' => $ort_kurzbz));
if (isError($reservation_hours))
return $reservation_hours;
$reservation_hours = hasData($reservation_hours) ? array_column(getData($reservation_hours), 'stunde') : array();
if (!empty(array_intersect($stunden, $reservation_hours))
&& !$this->_ci->permissionlib->isBerechtigt('lehre/reservierungAdvanced'))
return error("lvplan/bereitsReserviert");
if (!empty($lektoren))
{
foreach ($lektoren as $lektor)
{
$insert = array('ort_kurzbz' => $ort_kurzbz,
'datum' => $startTime->format('Y-m-d'),
'titel' => $title,
'studiengang_kz' => is_null($studiengang) ? 0 : $studiengang,
'beschreibung' => $beschreibung,
'insertvon' => getAuthUID(),
'uid' => $lektor,
'semester' => is_null($semester) ? null : $semester,
'verband' => is_null($verband) ? null : $verband,
'gruppe' => is_null($gruppe) ? null : $gruppe,
'gruppe_kurzbz' => is_null($spezialgruppe) ? null : $spezialgruppe,
);
foreach ($stunden as $stunde)
{
$insert['stunde'] = $stunde;
$check_insert = $this->_ci->ReservierungModel->insert($insert);
if (isError($check_insert))
return $check_insert;
}
}
}
else
{
foreach ($stunden as $stunde)
{
$check_insert = $this->_ci->ReservierungModel->insert(array(
'ort_kurzbz' => $ort_kurzbz,
'uid' => getAuthUID(),
'stunde' => $stunde,
'datum' => $startTime->format('Y-m-d'),
'titel' => $title,
'studiengang_kz' => is_null($studiengang) ? 0 : $studiengang,
'beschreibung' => $beschreibung,
'insertvon' => getAuthUID()
));
if (isError($check_insert))
return $check_insert;
}
}
return success("Erfolgreich");
}
public function deleteReservation($reservierung_id)
{
$this->_ci =& get_instance();
$this->_ci->load->model('ressource/Reservierung_model', 'ReservierungModel');
$this->_ci->load->model('ressource/Stunde_model', 'StundeModel');
$this->_ci->load->library('PermissionLib');
$this->_ci->ReservierungModel->db->where_in('reservierung_id', $reservierung_id);
$reservation = $this->_ci->ReservierungModel->load();
if (isError($reservation))
return $reservation;
if (!hasData($reservation))
return error("Reservierungen nicht gefunden");
$reservations = getData($reservation);
$today = new DateTime();
foreach ($reservations as $reservierung)
{
if ($today->format('Y-m-d') > $reservierung->datum)
return error("Vergangene Reservierungen können nicht gelöscht werden");
if (($this->_ci->permissionlib->isBerechtigt('lehre/reservierung:begrenzt')) && ($reservierung->insertvon == getAuthUID() || $reservierung->uid === getAuthUID()))
{
$delete_result = $this->_ci->ReservierungModel->delete($reservierung->reservierung_id);
if (isError($delete_result))
return $delete_result;
}
}
return success("Erfolgreich");
}
public function getReservableMap($ort_kurzbz, $start_date, $end_date)
{
$this->_ci =& get_instance();
$this->_ci->load->model('ressource/Ort_model', 'OrtModel');
$this->_ci->load->library('PermissionLib');
$berechtigt_begrenzt = $this->_ci->permissionlib->isBerechtigt('lehre/reservierung:begrenzt', 'suid');
$berechtigt_erweitert = $this->_ci->permissionlib->isBerechtigt('lehre/reservierung', 'suid');
$ort_data = $this->_ci->OrtModel->load($ort_kurzbz);
if (isError($ort_data) || !hasData($ort_data))
return [];
$ort_data = getData($ort_data)[0];
if (!$ort_data->reservieren)
return [];
if (!$berechtigt_begrenzt && !$berechtigt_erweitert)
return [];
$start_ts = is_numeric($start_date) ? (int)$start_date : strtotime($start_date);
$end_ts = is_numeric($end_date) ? (int)$end_date : strtotime($end_date);
if (!$start_ts || !$end_ts)
return [];
if ($end_ts < $start_ts)
{
$tmp = $start_ts;
$start_ts = $end_ts;
$end_ts = $tmp;
}
$now = time();
$tage_min = defined('RES_TAGE_LEKTOR_MIN') ? (int)RES_TAGE_LEKTOR_MIN : 0;
$tage_bis = defined('RES_TAGE_LEKTOR_BIS') ? (int)RES_TAGE_LEKTOR_BIS : 0;
$datum_res_lektor_start = $this->jump_day($now, $tage_min - 1);
$datum_res_lektor_ende = $this->jump_day($now, $tage_bis);
$start_ymd_allowed = date('Y-m-d', $datum_res_lektor_start);
$end_ymd_allowed = date('Y-m-d', $datum_res_lektor_ende);
$result = [];
$current = strtotime(date('Y-m-d', $start_ts) . ' 00:00:00');
$end_day = strtotime(date('Y-m-d', $end_ts) . ' 00:00:00');
while ($current <= $end_day)
{
$ymd = date('Y-m-d', $current);
$result[$ymd] = ($ymd >= $start_ymd_allowed && $ymd <= $end_ymd_allowed) ? true : false;
$current = $this->jump_day($current, 1);
}
return success($result);
}
private function jump_day($timestamp, $days)
{
$days = (int)$days;
$prefix = ($days >= 0 ? '+' : '');
return strtotime($prefix . $days . ' days', $timestamp);
}
// start of the private functions ########################################################################################################
// function used to sort an array of studiensemester strings
@@ -11,4 +11,14 @@ class Freebusytyp_model extends DB_Model
$this->dbTable = 'campus.tbl_freebusytyp';
$this->pk = 'freebusytyp_kurzbz';
}
public function getAllFreeBusyTypes()
{
return $this->execReadOnlyQuery("SELECT * FROM " . $this->dbTable);
}
public function getActiveFreeBusyTypes()
{
return $this->execReadOnlyQuery("SELECT * FROM " . $this->dbTable . " WHERE is_active");
}
}
@@ -0,0 +1,151 @@
<?php
class CoodleSurveyExternalParticipant_model extends DB_Model
{
private $_ci; // Code igniter instance
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->dbTable = 'campus.tbl_coodle_survey_external_participants';
$this->pk = 'id';
$this->_ci =& get_instance();
}
public function getExternalParticipants($surveyId, $includeAccessKeys = false)
{
$selectedColumns = $includeAccessKeys ? "*" : "id, survey_id, name, email, selection";
$query = "SELECT $selectedColumns
FROM $this->dbTable
WHERE survey_id = $surveyId
";
return $this->execQuery($query);
}
public function getExternalParticipant($externalParticipantId, $includeAccessKey = false)
{
$selectedColumns = $includeAccessKey ? "*" : "id, survey_id, name, email, selection";
$query = "SELECT *
FROM $this->dbTable
WHERE id = $externalParticipantId
LIMIT 1
";
return $this->execQuery($query);
}
public function getExternalParticipantByAccessKey($surveyId, $accessKey)
{
$query = "SELECT *
FROM $this->dbTable
WHERE survey_id = $surveyId AND access_key = '$accessKey'
LIMIT 1
";
return $this->execQuery($query);
}
public function updateExternalParticipants($surveyId, $externalParticipants, $timeslots)
{
$participantsQuery = "SELECT *
FROM $this->dbTable
WHERE survey_id = $surveyId
";
$oldExternalParticipants = getData($this->execQuery($participantsQuery));
$oldExternalParticipantIds = array_map(
function ($oldExternalParticipant) {
return $oldExternalParticipant->id;
},
$oldExternalParticipants
);
$persistingExternalParticipants = array_filter(
$externalParticipants,
function ($externalParticipant) {
return isset($externalParticipant["id"]) && $externalParticipant["id"];
}
);
$persistingExternalParticipantIds = array_map(
function ($persistingExternalParticipant) {
return $persistingExternalParticipant["id"];
},
$persistingExternalParticipants
);
$redundantExternalParticipantIds = array_diff(
$oldExternalParticipantIds,
$persistingExternalParticipantIds
);
$newExternalParticipants = array_filter(
$externalParticipants,
function ($externalParticipant) {
return !isset($externalParticipant["id"]) || !$externalParticipant["id"];
}
);
if (count($redundantExternalParticipantIds)) {
$idsToBeDeleted = "(" . implode(", ", $redundantExternalParticipantIds) . ")";
$deleteQuery = "DELETE FROM $this->dbTable WHERE id IN $idsToBeDeleted";
$this->execQuery($deleteQuery);
}
if (count($newExternalParticipants)) {
$insertValues = array_map(
function ($newExternalParticipant) use ($surveyId) {
$name = $newExternalParticipant["name"];
$email = $newExternalParticipant["email"];
$accessKey = uniqid();
return "($surveyId, '$name', '$email', '$accessKey', NULL)";
},
$newExternalParticipants
);
$insertValues = implode(", ", $insertValues);
$insertQuery = "INSERT INTO $this->dbTable (survey_id, name, email, access_key, selection) VALUES $insertValues";
$this->execQuery($insertQuery);
}
$updatedExternalParticipants = getData($this->execQuery($participantsQuery));
$timeslotIds = array_map(
function ($timeslot) {
return $timeslot->id;
},
$timeslots
);
foreach ($updatedExternalParticipants as $externalParticipant) {
if (!$externalParticipant->selection)
continue;
$selection = json_decode($externalParticipant->selection, true);
if (!count($selection))
continue;
$obsoleteSelectedTimeslotIds = array_diff($selection, $timeslotIds);
if (!count($obsoleteSelectedTimeslotIds))
continue;
$updatedSelection = null;
if (count($obsoleteSelectedTimeslotIds) !== count($selection)) {
$updatedSelection = array_filter(
$selection,
function ($timeslotId) use ($obsoleteSelectedTimeslotIds) {
return !in_array($timeslotId, $obsoleteSelectedTimeslotIds);
}
);
}
$this->updateSelection($externalParticipant->id, $updatedSelection);
}
}
public function updateSelection($externalParticipantId, $selection)
{
$selection = $selection !== null ? "'" . json_encode($selection) . "'" : "NULL";
$selectionUpdateQuery = "UPDATE $this->dbTable SET selection = $selection WHERE id = $externalParticipantId";
$this->execQuery($selectionUpdateQuery);
}
}
@@ -0,0 +1,137 @@
<?php
class CoodleSurveyParticipant_model extends DB_Model
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->dbTable = 'campus.tbl_coodle_survey_participants';
}
public function getParticipants($surveyId)
{
$query = "
SELECT
surveyParticipant.participant_uid as uid,
surveyParticipant.selection as selection,
person.vorname || ' ' || person.nachname AS name
FROM $this->dbTable surveyParticipant
JOIN public.tbl_benutzer benutzer ON(benutzer.uid = surveyParticipant.participant_uid)
JOIN public.tbl_person person ON(person.person_id = benutzer.person_id)
WHERE survey_id = $surveyId
";
return $this->execQuery($query);
}
public function getParticipantEntriesByUid($uid)
{
$query = "SELECT * FROM $this->dbTable WHERE participant_uid = '$uid'";
return $this->execQuery($query);
}
public function getActiveParticipantEntriesByUid($uid)
{
$query = "SELECT
surveyParticipant.survey_id as survey_id,
surveyParticipant.participant_uid as participant_uid,
surveyParticipant.selection as selection
FROM $this->dbTable surveyParticipant
JOIN campus.tbl_coodle_surveys survey ON(survey.id = surveyParticipant.survey_id)
WHERE surveyParticipant.participant_uid = '$uid'
AND survey.completed_at IS NULL
AND survey.canceled_at IS NULL";
return $this->execQuery($query);
}
public function updateParticipants($surveyId, $participants, $timeslots)
{
$participantUids = array_map(function ($participant) {
return $participant["uid"];
}, $participants);
$existingParticipantsQuery = "SELECT * FROM " . $this->dbTable . " WHERE survey_id = " . $surveyId;
$existingParticipants = getData($this->execQuery($existingParticipantsQuery));
$existingParticipantUids = array_map(
function ($existingParticipant) {
return $existingParticipant->participant_uid;
},
$existingParticipants
);
$newParticipantUids = array_filter(
$participantUids,
function ($participantUid) use ($existingParticipantUids) {
return !in_array($participantUid, $existingParticipantUids);
}
);
$redundantParticipantUids = array_filter(
$existingParticipantUids,
function ($existingParticipantUid) use ($participantUids) {
return !in_array($existingParticipantUid, $participantUids);
}
);
if (count($newParticipantUids)) {
$insertQuery = "INSERT INTO $this->dbTable VALUES ";
$newParticipantInsertValues = array_map(
function ($newParticipantUid) use ($surveyId) {
return "('$surveyId', '$newParticipantUid', NULL)";
},
$newParticipantUids
);
$newParticipantInsertValues = implode(", ", $newParticipantInsertValues);
$insertQuery .= $newParticipantInsertValues;
$insertQuery .= ";";
$this->execQuery($insertQuery);
}
if (count($redundantParticipantUids)) {
$deleteQuery = "DELETE FROM $this->dbTable WHERE survey_id = $surveyId AND participant_uid IN ('" . implode("', '", $redundantParticipantUids) . "');";
$this->execQuery($deleteQuery);
}
$timeslotIds = array_map(
function ($timeslot) {
return $timeslot->id;
},
$timeslots
);
$updatedExistingParticipants = getData($this->execQuery($existingParticipantsQuery));
foreach ($updatedExistingParticipants as $participant) {
if (!$participant->selection)
continue;
$selection = json_decode($participant->selection, true);
if (!count($selection))
continue;
$obsoleteSelectedTimeslotIds = array_diff($selection, $timeslotIds);
if (!count($obsoleteSelectedTimeslotIds))
continue;
$updatedSelection = null;
if (count($obsoleteSelectedTimeslotIds) !== count($selection)) {
$updatedSelection = array_filter(
$selection,
function ($timeslotId) use ($obsoleteSelectedTimeslotIds) {
return !in_array($timeslotId, $obsoleteSelectedTimeslotIds);
}
);
}
$this->updateSelection($surveyId, $participant->participant_uid, $updatedSelection);
}
}
public function updateSelection($surveyId, $participantUid, $selection)
{
$selection = $selection !== null ? "'" . json_encode($selection) . "'" : "NULL";
$selectionUpdateQuery = "UPDATE $this->dbTable SET selection = $selection WHERE survey_id = $surveyId AND participant_uid = '$participantUid'";
$this->execQuery($selectionUpdateQuery);
}
}
@@ -0,0 +1,100 @@
<?php
class CoodleSurveyTimeslot_model extends DB_Model
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->dbTable = 'campus.tbl_coodle_survey_timeslots';
$this->pk = 'id';
}
public function getTimeslots($surveyId)
{
$query = "SELECT id, starts_at
FROM $this->dbTable
WHERE survey_id = $surveyId
";
return $this->execQuery($query);
}
public function getTimeslotsForMultipleSurveys($surveyIds)
{
if (!count($surveyIds)) return [];
$surveyIds = "(" . implode(", ", $surveyIds) . ")";
$query = "SELECT *
FROM $this->dbTable
WHERE survey_id IN $surveyIds
";
return $this->execQuery($query);
}
public function getTimeslot($timeslotId)
{
$query = "SELECT *
FROM $this->dbTable
WHERE id = $timeslotId
LIMIT 1
";
return $this->execQuery($query);
}
public function updateTimeslots($surveyId, $timeslots)
{
$newTimeslots = array_filter(
$timeslots,
function ($timeslot) {
return !$timeslot["id"];
}
);
$persistedTimeslots = array_filter(
$timeslots,
function ($timeslot) {
return $timeslot["id"];
}
);
$persistedTimeslotIds = array_map(
function ($persistedTimeslot) {
return $persistedTimeslot["id"];
},
$persistedTimeslots
);
$existingTimeslotsQuery = "SELECT id, starts_at
FROM $this->dbTable
WHERE survey_id = $surveyId
";
$existingTimeslots = getData($this->execQuery($existingTimeslotsQuery));
$existingTimeslotIds = array_map(
function ($existingTimeslot) {
return $existingTimeslot->id;
},
$existingTimeslots
);
$obsoleteTimeslotIds = array_diff($existingTimeslotIds, $persistedTimeslotIds);
if (count($obsoleteTimeslotIds)) {
$deleteQuery = "DELETE FROM $this->dbTable WHERE id IN ('" . implode("', '", $obsoleteTimeslotIds) . "');";
$this->execQuery($deleteQuery);
}
if (count($newTimeslots)) {
$insertQuery = "INSERT INTO $this->dbTable (survey_id, starts_at) VALUES ";
$newTimeslotInsertValues = array_map(
function ($newTimeslot) use ($surveyId) {
return "('$surveyId', '" . $newTimeslot["startsAt"] . "')";
},
$newTimeslots
);
$newTimeslotInsertValues = implode(", ", $newTimeslotInsertValues);
$insertQuery .= $newTimeslotInsertValues;
$insertQuery .= ";";
$this->execQuery($insertQuery);
}
}
}
@@ -0,0 +1,164 @@
<?php
class CoodleSurvey_model extends DB_Model
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct();
$this->dbTable = 'campus.tbl_coodle_surveys';
$this->pk = 'id';
$this->load->model('ressource/CoodleSurveyParticipant_model', 'CoodleSurveyParticipantModel');
}
public function getSurvey($surveyId)
{
$query = "SELECT *
FROM $this->dbTable
WHERE id = $surveyId
LIMIT 1
";
return $this->execQuery($query);
}
public function getSurveys($surveyIds)
{
if (!count($surveyIds)) return [];
$surveyIds = "(" . implode(", ", $surveyIds) . ")";
$query = "SELECT *
FROM $this->dbTable
WHERE id IN $surveyIds
";
return $this->execQuery($query);
}
public function getActiveSurveys($uid)
{
$userParticipantEntries = getData($this->CoodleSurveyParticipantModel->getParticipantEntriesByUid($uid));
$surveyIdsWhereUserIsParticipant = array_map(
function ($userParticipantEntry) {
return $userParticipantEntry->survey_id;
},
$userParticipantEntries
);
$surveyIdsWhereUserIsParticipantValue = implode(", ", $surveyIdsWhereUserIsParticipant);
$query = "SELECT
survey.id as id,
survey.creator_uid as creator_uid,
survey.title as title,
survey.ends_at as ends_at,
survey.created_at as created_at,
person.vorname || ' ' || person.nachname AS creator_name
FROM $this->dbTable as survey
JOIN public.tbl_benutzer benutzer ON(benutzer.uid = survey.creator_uid)
JOIN public.tbl_person person ON(person.person_id = benutzer.person_id)
WHERE (";
if (strlen($surveyIdsWhereUserIsParticipantValue)) {
$query .= "id IN ($surveyIdsWhereUserIsParticipantValue) OR ";
}
$query .= "creator_uid = '$uid')
AND completed_at IS NULL
AND canceled_at IS NULL";
return $this->execQuery($query);
}
public function getInactiveSurveys($uid)
{
$userParticipantEntries = getData($this->CoodleSurveyParticipantModel->getParticipantEntriesByUid($uid));
$surveyIdsWhereUserIsParticipant = array_map(
function ($userParticipantEntry) {
return $userParticipantEntry->survey_id;
},
$userParticipantEntries
);
$surveyIdsWhereUserIsParticipantValue = implode(", ", $surveyIdsWhereUserIsParticipant);
$query = "SELECT
survey.id as id,
survey.creator_uid as creator_uid,
survey.title as title,
survey.created_at as created_at,
survey.completed_at as completed_at,
survey.canceled_at as canceled_at,
person.vorname || ' ' || person.nachname AS creator_name
FROM $this->dbTable as survey
JOIN public.tbl_benutzer benutzer ON(benutzer.uid = survey.creator_uid)
JOIN public.tbl_person person ON(person.person_id = benutzer.person_id)
WHERE (";
if (strlen($surveyIdsWhereUserIsParticipantValue)) {
$query .= "id IN ($surveyIdsWhereUserIsParticipantValue) OR ";
}
$query .= "creator_uid = '$uid')
AND (completed_at IS NOT NULL OR canceled_at IS NOT NULL)";
return $this->execQuery($query);
}
public function createSurvey($surveyData, $creatorUid)
{
$surveyCreationResult = $this->insert([
"creator_uid" => $creatorUid,
"title" => $surveyData["title"],
"description" => $surveyData["description"] ?? "",
"timeslot_duration" => $surveyData["timeslotDuration"],
"are_selections_anonymized" => !!$surveyData["areSelectionsAnonymized"],
"are_participants_anonymized" => !!$surveyData["areParticipantsAnonymized"],
"max_selections" => $surveyData["maxSelections"] ?? 1,
"selected_timeslot_id" => null,
"ends_at" => $surveyData["endsAt"],
"completed_at" => null,
"canceled_at" => null,
"created_at" => "NOW()",
"updated_at" => "NOW()",
]);
if (isError($surveyCreationResult)) {
return error('Something went wrong during survey creation!');
}
return $surveyCreationResult;
}
public function updateSurvey($surveyId, $surveyData)
{
$surveyUpdateResult = $this->update($surveyId, [
"title" => $surveyData["title"],
"description" => $surveyData["description"],
"timeslot_duration" => $surveyData["timeslotDuration"],
"are_selections_anonymized" => $surveyData["areSelectionsAnonymized"],
"are_participants_anonymized" => $surveyData["areParticipantsAnonymized"],
"max_selections" => $surveyData["maxSelections"],
"ends_at" => $surveyData["endsAt"],
"updated_at" => "NOW()",
]);
if (isError($surveyUpdateResult)) {
return error('Something went wrong during survey update!');
}
return $surveyUpdateResult;
}
public function cancelSurvey($surveyId)
{
$this->update($surveyId, [
"canceled_at" => "NOW()",
"updated_at" => "NOW()",
]);
}
public function completeSurvey($surveyId, $timeslotId)
{
$this->update($surveyId, [
"selected_timeslot_id" => $timeslotId,
"completed_at" => "NOW()",
"updated_at" => "NOW()",
]);
}
}
@@ -12,6 +12,12 @@ class Zeitsperre_model extends DB_Model
$this->pk = 'zeitsperre_id';
}
public function getZeitsperre($uid)
{
$query = "SELECT * from campus.tbl_zeitsperre WHERE mitarbeiter_uid = '$uid'";
return $this->execQuery($query);
}
/**
* Save or update Zeitsperre.
*
@@ -16,7 +16,7 @@ $query = '
anrechnung.begruendung_id,
anrechnung.dms_id,
CASE
WHEN stg.typ || stg.kurzbz IN (' . $ORGANISATIONSEINHEITEN_SCHREIBBERECHTIGT . ') THEN TRUE
WHEN stg.oe_kurzbz IN (' . $ORGANISATIONSEINHEITEN_SCHREIBBERECHTIGT . ') THEN TRUE
ELSE FALSE
END "schreibberechtigt",
anrechnung.studiensemester_kurzbz,
@@ -57,7 +57,7 @@
<div class="row<?php if ($lockedbyother) echo ' alert-danger' ?>">
<div class="col-lg-8">
<h3 class="page-header">
Infocenter Details: <a target="_blank" title="Studentenverwaltung" href="<?php echo site_url('/Studentenverwaltung/' . $studiensemester . '/person/' . $stammdaten->person_id) ?>"><?php echo $stammdaten->vorname.' '.$stammdaten->nachname ?> <i class="fa fa-external-link" style="font-size:small"></i></a>
Infocenter Details: <a target="_blank" title="Studentenverwaltung" href="<?php echo site_url('/studvw/stdsem/' . $studiensemester . '/person/' . $stammdaten->person_id) ?>"><?php echo $stammdaten->vorname.' '.$stammdaten->nachname ?> <i class="fa fa-external-link" style="font-size:small"></i></a>
</h3>
</div>
<div class="col-lg-4">
@@ -29,10 +29,11 @@ $this->load->view('templates/FHC-Header', $includesArray);
<cis-menu
root-url="<?= site_url(''); ?>"
logo-url="<?= base_url($this->config->item('theme_logo')); ?>"
avatar-url="<?= site_url('Cis/Pub/bild/person/' . getAuthPersonId()); ?>"
avatar-url="<?= isset($isExternal) && $isExternal ? '' : site_url('Cis/Pub/bild/person/' . getAuthPersonId()); ?>"
logout-url="<?= site_url('Cis/Auth/logout'); ?>"
:searchbaroptions="searchbaroptions"
:searchfunction="searchfunction"
:is-external="<?= isset($isExternal) && $isExternal ? 1 : 0 ?>"
></cis-menu>
</header>
+1 -1
View File
@@ -351,7 +351,7 @@ if (!$berechtigung->isBerechtigt('admin')
{
$worksheet->write($lines,3,$elem->vorname);
}
$worksheet->write($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
$worksheet->writeString($lines,4,$elem->semester.$elem->verband.$elem->gruppe);
$worksheet->write($lines,5,trim($elem->matrikelnr),$format_highlight);
$worksheet->write($lines,6, $note, $format_highlightright);
+21 -1145
View File
File diff suppressed because it is too large Load Diff
+4 -71
View File
@@ -25,77 +25,10 @@
* http://www.example.com/cis/public/freebusy_coodle.php/oesi
*/
require_once('../../config/cis.config.inc.php');
require_once('../../include/benutzer.class.php');
require_once('../../include/functions.inc.php');
require_once('../../include/coodle.class.php');
require_once('../../include/ical.class.php');
$uid = mb_substr($_SERVER['PATH_INFO'],1);
$uid = mb_substr($_SERVER['PATH_INFO'], 1);
$excludeSurveyQueryParam = isset($_GET['excludeSurvey']) ? '?excludeSurvey=' . $_GET['excludeSurvey'] : '';
$bn = new benutzer();
if(!$bn->load($uid))
die('User invalid');
header("Content-Type: text/calendar; charset=UTF-8");
echo "BEGIN:VCALENDAR\n";
echo "VERSION:2.0\n";
echo "PRODID:-//FH TECHNIKUM WIEN//EN\n";
echo "METHOD:PUBLISH\n";
echo 'ORGANIZER;CN=',$bn->vorname,' ',$bn->nachname,':mailto:',$uid,'@',DOMAIN,"\n";
echo 'DTSTAMP;TZID=Europe/Vienna:',date('Ymd', mktime(date('H'),date('i'),date('s'),date('m'),date('d')-5,date('Y'))),'T',date('Hms'),"\n";
echo 'DTSTART;TZID=Europe/Vienna:',date('Ymd', mktime(0,0,0,date('m'),date('d')-5,date('Y'))),"T000000\n";
echo 'DTEND;TZID=Europe/Vienna:',date('Ymd', mktime(0,0,0,date('m'),date('d')+30,date('Y'))),"T000000\n";
echo 'URL:',APP_ROOT,'cis/public/freebusy_coodle.php/',$uid,"\n";
echo "BEGIN:VFREEBUSY";
// Alle Umfragen holen an denen der User beteiligt ist
$umfragen = new coodle();
$umfragen->getCoodleFromUser($uid);
foreach($umfragen->result as $umfrage)
{
if($umfrage->coodle_status_kurzbz=='laufend')
{
// Bei laufenden Umfragen werden alle angekreuzten Vorschlaege zur FreeBusy hinzugefuegt
$ressource = new coodle();
if($ressource_id = $ressource->RessourceExists($umfrage->coodle_id, $uid))
{
// Terminvorschlaege laden die angekreuzt wurden
$termine = new coodle();
$termine->getRessourceTermin($umfrage->coodle_id, $ressource_id);
foreach($termine->result as $termin)
{
//Start und Ende berechnen
$date = new DateTime($termin->datum.' '.$termin->uhrzeit);
$dtstart = $date->format('Ymd\THis');
$interval =new DateInterval('PT'.$umfrage->dauer.'M');
$date->add($interval);
$uhrzeit_ende = $date->format('H:i:s');
$dtende = $date->format('Ymd\THis');
echo "\nFREEBUSY;TZID=Europe/Vienna: $dtstart/$dtende";
}
}
}
elseif($umfrage->coodle_status_kurzbz=='abgeschlossen')
{
// Bei abgeschlossenen Umfragen wird nur mehr der ausgewaehlte Termin zur FreeBusy hinzugefuegt
$termin = new coodle();
$coodle_termin_id=$termin->getTerminAuswahl($umfrage->coodle_id);
if($termin->loadTermin($coodle_termin_id))
{
//Start und Ende berechnen
$date = new DateTime($termin->datum.' '.$termin->uhrzeit);
$dtstart = $date->format('Ymd\THis');
$interval =new DateInterval('PT'.$umfrage->dauer.'M');
$date->add($interval);
$uhrzeit_ende = $date->format('H:i:s');
$dtende = $date->format('Ymd\THis');
echo "\nFREEBUSY;TZID=Europe/Vienna: $dtstart/$dtende";
}
}
// stornierte Umfragen werden nicht beruecksichtigt
}
echo "\nEND:VFREEBUSY";
echo "\nEND:VCALENDAR";
header('Location: ' . APP_ROOT . 'cis.php/CoodleFreeBusy/' . $uid . $excludeSurveyQueryParam);
exit;
?>
+6 -125
View File
@@ -23,140 +23,21 @@
* Aufruf: http://www.example.com/cis/public/ical_coodle.php/[uid]
*/
require_once('../../config/cis.config.inc.php');
require_once('../../include/benutzer.class.php');
require_once('../../include/functions.inc.php');
require_once('../../include/coodle.class.php');
require_once('../../include/ical.class.php');
$params = mb_substr($_SERVER['PATH_INFO'],1);
$paramsArray = explode('/',$params);
$private = false;
// redirect to new coodle ical
if ($paramsArray[0] == 'cipher_encryption')
{
$uid = decryptData($paramsArray[1],LVPLAN_CYPHER_KEY);
$private = true;
$encryptedKey = $paramsArray[1];
header('Location: '.APP_ROOT.'cis.php/CoodleIcal/encrypted/' . $encryptedKey);
exit;
}
else
{
$uid = $paramsArray[0];
header('Location: '.APP_ROOT.'cis.php/CoodleIcal/' . $uid);
exit;
}
$bn = new benutzer();
if(!$bn->load($uid))
die('User invalid');
header("Content-Type: text/calendar; charset=UTF-8");
header("Content-disposition: filename=Coodle Terminzusagen.ics");
echo "BEGIN:VCALENDAR\n";
echo "VERSION:2.0\n";
echo "PRODID:-//FH TECHNIKUM WIEN//EN\n";
//echo "METHOD:PUBLISH\n";
//echo 'ORGANIZER;CN=',$bn->vorname,' ',$bn->nachname,':mailto:',$uid,'@',DOMAIN,"\n";
//echo 'DTSTAMP;TZID=Europe/Vienna:',date('Ymd', mktime(date('H'),date('i'),date('s'),date('m'),date('d')-5,date('Y'))),'T',date('Hms'),"\n";
echo 'DTSTART;TZID=Europe/Vienna:',date('Ymd', mktime(0,0,0,date('m'),date('d')-5,date('Y'))),"T000000\n";
echo 'DTEND;TZID=Europe/Vienna:',date('Ymd', mktime(0,0,0,date('m'),date('d')+30,date('Y'))),"T000000\n";
echo "BEGIN:VTIMEZONE
TZID:Europe/Vienna
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+02:00
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+01:00
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE\n";
//echo 'URL:',APP_ROOT,'cis/public/ical_coodle.php/',$uid,"\n";
// Alle Umfragen holen an denen der User beteiligt ist
$umfragen = new coodle();
$umfragen->getCoodleFromUser($uid);
$i = 0;
if (count($umfragen->result) > 0)
{
//echo "BEGIN:VEVENT";
}
foreach($umfragen->result as $umfrage)
{
if($umfrage->coodle_status_kurzbz=='laufend')
{
// Bei laufenden Umfragen werden alle angekreuzten Vorschlaege zur FreeBusy hinzugefuegt
$ressource = new coodle();
if($ressource_id = $ressource->RessourceExists($umfrage->coodle_id, $uid))
{
// Terminvorschlaege laden die angekreuzt wurden
$termine = new coodle($umfrage->coodle_id);
$termine->getRessourceTermin($umfrage->coodle_id, $ressource_id);
foreach($termine->result as $termin)
{
//Start und Ende berechnen
$date = new DateTime($termin->datum.' '.$termin->uhrzeit);
$dtstart = $date->format('Ymd\THis');
$interval =new DateInterval('PT'.$umfrage->dauer.'M');
$date->add($interval);
$uhrzeit_ende = $date->format('H:i:s');
$dtende = $date->format('Ymd\THis');
$ersteller = new benutzer($termine->ersteller_uid);
if ($private == true)
{
$summary = 'Coodle Terminoption '.strip_tags($termine->titel);
$description = 'Erstellt von '.$ersteller->vorname.' '.$ersteller->nachname;
}
else
{
$summary = 'Coodle Terminoption';
$description = '';
}
echo "\nBEGIN:VEVENT";
echo "\nUID:Coodle_Terminoption".$dtstart."_".$dtende;
echo "\nSUMMARY:".$summary;
echo "\nDESCRIPTION:".$description;
echo "\nDTSTART;TZID=Europe/Vienna:$dtstart";
echo "\nDTEND;TZID=Europe/Vienna:$dtende";
echo "\nTRANSP:OPAQUE";
echo "\nEND:VEVENT";
$i++;
}
}
}
// Bei abgeschlossene Umfragen wird ein iCal File mitgeschickt,
// deshalb müssen diese Termine nicht berücksichtigt werden
/*elseif($umfrage->coodle_status_kurzbz=='abgeschlossen')
{
// Bei abgeschlossenen Umfragen wird nur mehr der ausgewaehlte Termin zur FreeBusy hinzugefuegt
$termin = new coodle();
$coodle_termin_id=$termin->getTerminAuswahl($umfrage->coodle_id);
if($termin->loadTermin($coodle_termin_id))
{
//Start und Ende berechnen
$date = new DateTime($termin->datum.' '.$termin->uhrzeit);
$dtstart = $date->format('Ymd\THis');
$interval =new DateInterval('PT'.$umfrage->dauer.'M');
$date->add($interval);
$uhrzeit_ende = $date->format('H:i:s');
$dtende = $date->format('Ymd\THis');
echo "\nBEGIN:VEVENT";
echo "\nSUMMARY:Coodle Terminzusage";
echo "\nDTSTART;TZID=Europe/Vienna: $dtstart";
echo "\nDTEND;TZID=Europe/Vienna: $dtende";
echo "\nTRANSP:OPAQUE";
echo "\nEND:VEVENT";
}
}*/
// stornierte Umfragen werden nicht beruecksichtigt
}
echo "\nEND:VCALENDAR";
?>
+1 -1
View File
@@ -121,7 +121,7 @@ class vertrag extends basis_db
JOIN lehre.tbl_lehreinheit USING(lehreinheit_id)
WHERE
vertrag_id=tbl_vertrag.vertrag_id
AND studiensemester_kurzbz in (SELECT studiensemester_kurzbz FROM public.tbl_studiensemester WHERE start>=".$this->db_add_param($datum).")
AND studiensemester_kurzbz in (SELECT studiensemester_kurzbz FROM public.tbl_studiensemester WHERE start>".$this->db_add_param($datum).")
)
)";
}
+67
View File
@@ -33,6 +33,10 @@ html {
background-color: var(--fhc-primary) !important;
}
.fhc-primary-border-color {
border-color: var(--fhc-primary) !important;
}
.fhc-primary-highlight-bg {
background-color: var(--fhc-primary-highlight) !important;
}
@@ -853,4 +857,67 @@ html {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
#coodleParticipantsSearchResults {
background-color: var(--fhc-background);
border: 1px solid var(--fhc-border);
}
.coodleParticipantSearchResult:hover {
opacity: 80%;
}
.coodleCalendarTimeslotCard:hover {
background-color: var(--fhc-background);
}
.coodlePrimaryHover:hover {
color: var(--fhc-primary);
}
#coodleCalendar .fhc-calendar-base .part-body {
z-index: 1 !important;
}
#coodleCalendar .fhc-calendar-base .fhc-calendar-base-grid-line-background {
z-index: 1 !important;
pointer-events: none;
}
#coodleCalendar .fhc-calendar-base-grid-line-event {
pointer-events: none;
}
#coodleCalendar .coodle-calendar-event-clear {
pointer-events: auto;
}
#freeBusyTable th {
background-color: var(--fhc-primary) !important;
color: var(--fhc-light) !important;
}
#freeBusyTable .action:hover {
opacity:80%;
}
.fhcPrimaryHover:hover {
color: var(--fhc-primary);
}
.fhcPrimaryBorderHover:hover {
border-color: var(--fhc-primary) !important;
}
button.tabulator-page {
background-color: var(--fhc-primary) !important;
color: var(--fhc-light) !important;
}
button.tabulator-page:hover {
opacity: 90%;
}
button.tabulator-page.active {
border: solid 2px var(--fhc-primary) !important;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
export default {
searchParticipants(searchString) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/searchParticipants",
params: {
searchString,
},
};
},
createSurvey(surveyData) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/createSurvey",
params: {
surveyData,
},
};
},
updateSurvey(surveyId, surveyData, shouldInformParticipants) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/updateSurvey",
params: {
surveyId,
surveyData,
shouldInformParticipants,
},
};
},
getSurvey(surveyId) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/getSurvey",
params: {
surveyId,
},
};
},
getSurveyForExternalParticipant(surveyId, accessKey) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/getSurveyForExternalParticipant",
params: {
surveyId,
accessKey,
},
};
},
getActiveSurveys() {
return {
method: "get",
url: "/api/frontend/v1/coodle/CoodleSurvey/getActiveSurveys",
};
},
getInactiveSurveys() {
return {
method: "get",
url: "/api/frontend/v1/coodle/CoodleSurvey/getInactiveSurveys",
};
},
submitParticipantSelection(surveyId, selection) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/submitParticipantSelection",
params: {
surveyId,
selection,
},
};
},
submitExternalParticipantSelection(surveyId, accessKey, selection) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/submitExternalParticipantSelection",
params: {
surveyId,
accessKey,
selection,
},
};
},
cancelSurvey(surveyId) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/cancelSurvey",
params: {
surveyId,
},
};
},
completeSurvey(surveyId, selectedTimeslotId, selectedRoomId) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/completeSurvey",
params: {
surveyId,
selectedTimeslotId,
selectedRoomId,
},
};
},
sendReminders(surveyId) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/sendVotingReminders",
params: {
surveyId,
},
};
},
getCoodleIcalLink() {
return {
method: "get",
url: "/api/frontend/v1/coodle/CoodleSurvey/getCoodleIcalUrl",
};
},
emailParticipants(surveyId, emailType, additionalData = null) {
return {
method: "post",
url: "/api/frontend/v1/coodle/CoodleSurvey/emailParticipants",
params: {
surveyId,
emailType,
additionalData,
},
};
},
};
+64
View File
@@ -0,0 +1,64 @@
/**
* Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
*/
export default {
getFreeBusyEntries() {
return {
method: "get",
url: "/api/frontend/v1/coodle/FreeBusy/getFreeBusyEntries",
};
},
createFreeBusyEntry(params) {
return {
method: "post",
url: "/api/frontend/v1/coodle/FreeBusy/createFreeBusyEntry",
params,
};
},
updateFreeBusyEntry(params) {
return {
method: "post",
url: "/api/frontend/v1/coodle/FreeBusy/updateFreeBusyEntry",
params,
};
},
deleteFreeBusyEntry(id) {
return {
method: "post",
url: "/api/frontend/v1/coodle/FreeBusy/deleteFreeBusyEntry",
params: {
id,
},
};
},
getFreeBusyTypes() {
return {
method: "get",
url: "/api/frontend/v1/coodle/FreeBusy/getFreeBusyTypes",
};
},
getFreeBusySchedule(uid, coodleSurveyIdToExclude) {
return {
method: "post",
url: "/api/frontend/v1/coodle/FreeBusy/getFreeBusySchedule",
params: {
uid,
coodleSurveyIdToExclude
},
};
},
};
+14
View File
@@ -10,6 +10,8 @@ import Profil from "../../components/Cis/Profil/Profil.js";
import Raumsuche from "../../components/Cis/Raumsuche/Raumsuche.js";
import CmsNews from "../../components/Cis/Cms/News.js";
import CmsContent from "../../components/Cis/Cms/Content.js";
import Coodle from "../../components/Cis/Coodle/Coodle.js";
import CoodleExternal from "../../components/Cis/Coodle/CoodleExternal.js";
import Info from "../../components/Cis/Mylv/Semester/Studiengang/Lv/Info.js";
import RoomInformation, {DEFAULT_MODE_RAUMINFO_DESKTOP, DEFAULT_MODE_RAUMINFO_MOBILE} from "../../components/Cis/Mylv/RoomInformation.js";
import AbgabetoolStudent from "../../components/Cis/Abgabetool/AbgabetoolStudent.js";
@@ -219,6 +221,18 @@ const router = VueRouter.createRouter({
};
}
},
{
path: `/Cis/Coodle`,
name: 'Coodle',
component: Coodle,
props: true
},
{
path: `/Cis/CoodleExternal/:surveyId/:accessKey`,
name: 'CoodleExternal',
component: CoodleExternal,
props: true
},
{
path: `/Cis4`,
name: 'Cis4',
+1 -1
View File
@@ -9,7 +9,7 @@ const app = Vue.createApp({
name: 'CisMenuApp',
components: {
FhcSearchbar,
CisMenu
CisMenu,
},
data: function() {
return {
+12 -3
View File
@@ -44,7 +44,10 @@ export default {
return () => true;
}),
hasDragoverFunc: Vue.computed(() => this.onDragover),
mode: Vue.computed(() => this.mode)
mode: Vue.computed(() => this.mode),
isAutoScrollEnabled: Vue.computed(
() => this.$props.isAutoScrollEnabled,
),
};
},
props: {
@@ -97,7 +100,11 @@ export default {
draggableEvents: [Boolean, Array, Function],
dropableEvents: [Boolean, Array, Function],
onDragover: Function,
onDrop: Function
onDrop: Function,
isAutoScrollEnabled: {
type: Boolean,
default: true,
}
},
emits: [
"click:next",
@@ -109,7 +116,8 @@ export default {
"update:date",
"update:mode",
"update:range",
"drop"
"drop",
"emptyClicked",
],
data() {
return {
@@ -293,6 +301,7 @@ export default {
@update:range="$emit('update:range', $event)"
@request-modal-open="showEventModal"
@request-modal-close="hideEventModal"
@emptyClicked="$emit('emptyClicked', $event)"
v-bind="modeOptions ? modeOptions[cMode] : null || {}"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
+9 -3
View File
@@ -2,6 +2,7 @@ import GridLine from './Grid/Line.js';
import GridLineEvent from './Grid/Line/Event.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../directives/Calendar/Click.js';
export default {
name: "CalendarGrid",
@@ -10,12 +11,13 @@ export default {
GridLineEvent
},
directives: {
CalDnd
CalDnd,
},
inject: {
originalEvents: "events",
originalBackgrounds: "backgrounds",
dropAllowed: "dropAllowed"
dropAllowed: "dropAllowed",
isAutoScrollEnabled: "isAutoScrollEnabled",
},
provide() {
return {
@@ -267,6 +269,10 @@ export default {
/* SCROLLING */
enableAutoScroll() {
if (!this.isAutoScrollEnabled) {
return;
}
if (!this.resizeObserver)
this.resizeObserver = new ResizeObserver(this.scrollToEarliestEvent);
this.resizeObserver.observe(this.$refs.body);
@@ -399,7 +405,7 @@ export default {
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<slot name="part-body" v-bind="{ index, part, date }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
@@ -144,6 +144,13 @@ export default {
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #part-body="params">
<slot name="part-body">
<div
@click="$emit('emptyClicked', { event: $event, params })"
class="position-absolute h-100 w-100"></div>
</slot>
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
+33 -19
View File
@@ -1,7 +1,7 @@
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';
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",
@@ -9,47 +9,54 @@ export default {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
LabelTime,
},
inject: {
timeGrid: "timeGrid",
timezone: "timezone"
timezone: "timezone",
},
props: {
day: {
type: luxon.DateTime,
required: true
required: true,
},
collapseEmptyDays: Boolean
collapseEmptyDays: Boolean,
},
computed: {
start() {
return this.day.startOf('week', { useLocaleWeeks: true });
return this.day.startOf("week", { useLocaleWeeks: true });
},
axisMain() {
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
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 this.timeGrid.map((tu) => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
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 }));
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');
}
return date.hasSame(
luxon.DateTime.now().setZone(this.timezone),
"day",
);
},
},
template: /* html */`
template: /* html */ `
<div class="fhc-calendar-mode-week-view h-100">
<calendar-grid
ref="grid"
@@ -73,6 +80,13 @@ export default {
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #part-body="params">
<slot name="part-body">
<div
@click="$emit('emptyClicked', { event: $event, params })"
class="position-absolute h-100 w-100"></div>
</slot>
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
@@ -81,5 +95,5 @@ export default {
</template>
</calendar-grid>
</div>
`
}
`,
};
@@ -0,0 +1,156 @@
import { CoreFilterCmpt } from "../../../filter/Filter.js";
import { dateFilter } from "../../../../tabulator/filters/Dates.js";
import CoodleApi from "../../../../api/factory/coodle.js";
export default {
name: "ActiveCoodleSurveys",
components: { CoreFilterCmpt },
emits: ["showSurveyDetails"],
data() {
return {
activeSurveys: [],
activeSurveysTableConfig: {
persistenceID: "activeSurveysTable",
minHeight: 300,
movableColumns: false,
layout: "fitColumns",
initialSort: [{ column: "createdAt", dir: "desc" }],
locale: true,
pagination: true,
paginationSize: 25,
paginationCounter: "rows",
columns: [
{
title: "placeholder",
titlePhrase: "coodle/title",
field: "title",
headerFilter: true,
minWidth: 150,
},
{
title: "placeholder",
titlePhrase: "coodle/creator",
field: "creatorName",
headerFilter: true,
minWidth: 100,
},
{
title: "placeholder",
titlePhrase: "coodle/started_on",
field: "createdAt",
headerFilter: true,
minWidth: 100,
formatter: "datetime",
formatterParams: this.datetimeFormatterParams(),
headerFilterFunc: "dates",
headerFilter: dateFilter,
},
{
title: "placeholder",
titlePhrase: "coodle/ends_on",
field: "endsAt",
headerFilter: true,
minWidth: 100,
formatter: "datetime",
formatterParams: this.datetimeFormatterParams(),
headerFilterFunc: "dates",
headerFilter: dateFilter,
},
],
},
isFetchingSurveys: false,
};
},
methods: {
async fetchActiveSurveys() {
this.isFetchingSurveys = true;
const surveysResponse = await this.$api.call(
CoodleApi.getActiveSurveys(),
);
this.isFetchingSurveys = false;
if (surveysResponse.meta.status !== "success") return;
this.activeSurveys = surveysResponse.data.map((surveyData) => {
return {
id: surveyData.id,
title: surveyData.title,
endsAt: surveyData.ends_at.split(".")[0],
createdAt: surveyData.created_at.split(".")[0],
creator: {
uid: surveyData.creator.uid,
name: surveyData.creator.name,
},
};
});
this.activeSurveysTableConfig.data = this.activeSurveys.map(
(survey) => {
return {
id: survey.id,
title: survey.title,
creatorName: survey.creator.name,
createdAt: survey.createdAt,
endsAt: survey.endsAt,
};
},
);
},
afterActiveSurveysTableBuilt() {
this.$refs.activeSurveysTable.tabulator.on(
"rowClick",
(event, row) => {
this.$emit("showSurveyDetails", {
surveyId: row._row.data.id,
});
},
);
},
datetimeFormatterParams: function () {
const params = {
inputFormat: "yyyy-MM-dd hh:mm:ss",
outputFormat: "dd.MM.yyyy",
invalidPlaceholder: "(invalid date)",
timezone: FHC_JS_DATA_STORAGE_OBJECT.timezone,
};
return params;
},
},
async created() {
await this.fetchActiveSurveys();
},
template: /*html*/ `
<div class="card mb-4" style="height:100%">
<div class="card-body">
<div
v-if="isFetchingSurveys"
class="d-flex justify-content-center align-items-center mt-5"
>
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ $p.t("coodle/loading") }}</span>
</div>
</div>
<div
v-else-if="!activeSurveys?.length"
class="d-flex justify-content-center align-items-center mt-5"
>
<h3 class="fw-bold">{{ $p.t("coodle/no_active_surveys_found") }}</h3>
</div>
<div v-else class="d-flex flex-row overflow-x-auto">
<div class="flex-shrink">
<core-filter-cmpt
@tableBuilt="afterActiveSurveysTableBuilt()"
:noColumnFilter="true"
:title="'Active surveys'"
:ref="'activeSurveysTable'"
:tabulatorOptions="activeSurveysTableConfig"
:sideMenu="false"
tableOnly
/>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,213 @@
import CoodleFreeBusyTableFormRow from "./CoodleFreeBusy/CoodleFreeBusyTableFormRow.js";
import FreeBusyApi from "../../../../api/factory/freeBusy.js";
export default {
name: "CoodleFreeBusy",
components: { CoodleFreeBusyTableFormRow },
props: {
authUid: Number | null,
},
data() {
return {
columnHeaders: [],
defaultSchedules: [
{
description: this.$p.t("coodle/freebusy_personal_schedule"),
type: this.$p.t("coodle/schedule"),
},
{
description: this.$p.t("coodle/freebusy_planned_absences"),
type: this.$p.t("coodle/absences"),
},
],
externalSchedules: [],
scheduleEditIndex: null,
scheduleFormData: null,
scheduleTypes: [],
isSubmittingScheduleForm: false,
};
},
methods: {
getScheduleTypeText(value) {
let type = this.scheduleTypes.find((type) => type.value === value);
return type?.text ?? "Other";
},
openScheduleForm(index) {
this.scheduleEditIndex = index;
this.setScheduleFormData(
index >= 0 ? this.externalSchedules[index] : null,
);
},
closeScheduleForm() {
this.scheduleEditIndex = null;
},
setScheduleFormData(schedule) {
if (schedule) {
this.scheduleFormData = { ...schedule };
} else {
this.scheduleFormData = {
id: null,
description: "",
type: null,
url: "",
isActive: true,
};
}
},
async submitScheduleForm() {
if (this.isSubmittingScheduleForm) return;
const response = await this.$api.call(
this.scheduleFormData.id
? FreeBusyApi.updateFreeBusyEntry(this.scheduleFormData)
: FreeBusyApi.createFreeBusyEntry(this.scheduleFormData),
);
if (response.meta.status === "success") {
this.closeScheduleForm();
this.getFreeBusySchedules();
}
},
async deleteSchedule(schedule) {
if (!schedule?.id) return;
const scheduleRemovalConfirmation = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/removal_confirmation"),
message:
this.$p.t("coodle/freebusy_removal_confirmation_message"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!scheduleRemovalConfirmation) return;
const scheduleDeletionResponse = await this.$api.call(
FreeBusyApi.deleteFreeBusyEntry(schedule.id),
);
if (scheduleDeletionResponse.meta.status === "success") {
this.getFreeBusySchedules();
}
},
async getFreeBusyTypes() {
const freeBusyTypesResponse = await this.$api.call(
FreeBusyApi.getFreeBusyTypes(),
);
this.scheduleTypes = freeBusyTypesResponse.data.map((type) => {
return {
value: type.freebusytyp_kurzbz,
text: type.bezeichnung,
urlDefault: type.url_default,
};
});
},
async getFreeBusySchedules() {
const freeBusyEntriesResponse = await this.$api.call(
FreeBusyApi.getFreeBusyEntries(),
);
this.externalSchedules = freeBusyEntriesResponse.data.map(
(freeBusySchedule) => {
return {
id: freeBusySchedule.freebusy_id,
description: freeBusySchedule.bezeichnung,
type: freeBusySchedule.freebusytyp_kurzbz,
url: freeBusySchedule.url,
isActive: freeBusySchedule.aktiv,
};
},
);
},
},
async created() {
await this.getFreeBusyTypes();
await this.getFreeBusySchedules();
},
template: /*html*/ `
<div class="card" style="min-height:100%">
<div class="card-header">
<h4>{{ $p.t("coodle/freebusy_settings") }}</h4>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
<div class="d-flex flex-column gap-2">
<span>
{{ $p.t("coodle/freebusy_explainer") }}
</span>
</div>
<div class="overflow-x-auto">
<table id="freeBusyTable" style="min-width:100%;">
<tr>
<th class="border-1 py-1 px-2 fw-bold">{{ $p.t("coodle/description") }}</th>
<th class="border-1 py-1 px-2 fw-bold">{{ $p.t("coodle/type") }}</th>
<th class="border-1 py-1 px-2 fw-bold" style="width: 50%;">{{ "URL" }}</th>
<th class="border-1 py-1 px-2 fw-bold">{{ $p.t("coodle/active") }}</th>
<th class="border-1 py-1 px-2 fw-bold">{{ $p.t("coodle/actions") }}</th>
</tr>
<tr v-for="defaultSchedule in defaultSchedules">
<td class="border-1 py-1 px-2">{{ defaultSchedule.description }}</td>
<td class="border-1 py-1 px-2">{{ defaultSchedule.type }}</td>
<td class="border-1 py-1 px-2"></td>
<td class="border-1 py-1 px-2">{{ $p.t("coodle/yes") }}</td>
<td class="border-1 py-1 px-2"></td>
</tr>
<template v-for="(schedule, index) in externalSchedules">
<tr v-if="scheduleEditIndex !== index">
<td class="border-1 py-1 px-2">{{ schedule.description }}</td>
<td class="border-1 py-1 px-2">{{ getScheduleTypeText(schedule.type) }}</td>
<td class="border-1 py-1 px-2">{{ schedule.url }}</td>
<td class="border-1 py-1 px-2">{{ schedule.isActive ? $p.t("coodle/yes") : $p.t("coodle/no") }}</td>
<td class="border-1 py-1 px-2">
<div class="w-100 d-flex flex-row gap-1 align-items-center justify-content-evenly">
<div
@click="openScheduleForm(index)"
:title="$p.t('coodle/edit')"
type="button"
class="action py-1 px-1"
>
<i class="fa-solid fa-pen-to-square"></i>
</div>
<div
@click="deleteSchedule(schedule)"
:title="$p.t('global/loeschen')"
type="button"
class="action py-1 px-1"
>
<i class="fa-solid fa-trash-can"></i>
</div>
</div>
</td>
</tr>
<coodle-free-busy-table-form-row
v-else
v-model:scheduleFormDataModelValue="scheduleFormData"
@cancelForm="closeScheduleForm()"
@submitForm="submitScheduleForm()"
:scheduleTypes="scheduleTypes"
/>
</template>
<tr v-if="scheduleEditIndex !== -1">
<td colspan="5" class="border-1">
<div
@click="openScheduleForm(-1)"
type="button"
class="action w-100 d-flex flex-row align-items-center justify-content-center py-2"
>
<div>
<i class="fa-solid fa-circle-plus fa-xl"></i>
</div>
</div>
</td>
</tr>
<coodle-free-busy-table-form-row
v-else
v-model:scheduleFormDataModelValue="scheduleFormData"
@cancelForm="closeScheduleForm()"
@submitForm="submitScheduleForm()"
:scheduleTypes="scheduleTypes"
/>
</table>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,77 @@
export default {
name: "CoodleFreeBusyTableFormRow",
props: {
scheduleFormDataModelValue: Object,
scheduleTypes: Array,
},
emits: ["update:scheduleFormDataModelValue", "submitForm", "cancelForm"],
computed: {
scheduleFormData: {
get() {
return this.$props.scheduleFormDataModelValue;
},
set(newValue) {
this.$emit("update:scheduleFormDataModelValue", newValue);
},
},
},
watch: {
"scheduleFormData.type": {
handler(newValue) {
const type = this.$props.scheduleTypes.find(
(type) => type.value === newValue,
);
if (!type || !type.urlDefault?.length) return;
this.scheduleFormData.url = type.urlDefault;
},
},
},
template: /*html*/ `
<tr>
<td class="border-1 py-2 px-2">
<input
v-model="scheduleFormData.description"
id="scheduleDescriptionInput"
type="text"
maxlength="255"
class="w-100"
/>
</td>
<td class="border-1 py-2 px-2">
<select v-model="scheduleFormData.type" id="scheduleTypeInput" class="w-100 h-100">
<option value="null">{{ "--" + $p.t("global/waehlen") + "--" }}</option>
<option v-for="type in $props.scheduleTypes" :value="type.value">{{ type.text }}</option>
</select>
</td>
<td class="border-1 py-2 px-2">
<input
v-model="scheduleFormData.url"
id="scheduleUrlInput"
type="text"
maxlength="255"
class="w-100"
/>
</td>
<td class="border-1 py-2 px-2">
<div class="d-flex flex-row align-items-center justify-content-center">
<input
v-model="scheduleFormData.isActive"
id="scheduleIsActiveInput"
type="checkbox"
style="height:15px; width:15px;"
/>
</div>
</td>
<td class="border-1 py-2 px-2">
<div class="w-100 d-flex flex-row gap-1 align-items-center justify-content-evenly">
<div @click="$emit('cancelForm')" :title="$p.t('ui/abbrechen')" type="button" class="action py-1 px-1">
<i class="fa-solid fa-circle-left"></i>
</div>
<div @click="$emit('submitForm')" :title="$p.t('global/speichern')" type="button" class="action py-1 px-1">
<i class="fa-solid fa-floppy-disk"></i>
</div>
</td>
</tr>
`,
};
@@ -0,0 +1,54 @@
import CoodleApi from "../../../../api/factory/coodle.js";
export default {
name: "CoodleIcal",
props: {
authUid: String | null,
},
data() {
return {
icalLink: "",
encryptedIcalLink: "",
};
},
computed: {
isDarkMode() {
return this.$theme.theme_name.value == "dark";
},
link() {
return this.icalLink;
},
},
methods: {
async getIcalLink() {
const icalLinkResponse = await this.$api.call(CoodleApi.getCoodleIcalLink());
if (icalLinkResponse.meta.status === "success") {
this.icalLink = icalLinkResponse.data.unencryptedUrl;
this.encryptedIcalLink = icalLinkResponse.data.encryptedUrl;
}
},
},
async created() {
await this.getIcalLink();
},
template: /*html*/ `
<div class="card" style="min-height:100%">
<div class="card-body">
<div class="d-flex flex-column gap-3">
<span>
{{ $p.t("coodle/ical_explainer") }}
</span>
<span>
{{ $p.t("coodle/ical_unencrypted") + ": " }}
<a v-if="icalLink?.length" :href="icalLink" target="_blank">{{ icalLink }}</a>
</span>
<span>
{{ $p.t("coodle/ical_encrypted") + ": " }}
<a v-if="encryptedIcalLink?.length" :href="encryptedIcalLink" target="_blank">{{ encryptedIcalLink }}</a>
</span>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,441 @@
import CoodleSurveyVotingTable from "./CoodleSurvey/CoodleSurveyVotingTable.js";
import CoodleSurveyCalendar from "./CoodleSurvey/CoodleSurveyCalendar.js";
import CoodleSurveyParticipants from "./CoodleSurvey/CoodleSurveyParticipants.js";
import CoodleSurveyDurationSelector from "./CoodleSurvey/CoodleSurveyDurationSelector.js";
import CoodleSurveyBasicInfo from "./CoodleSurvey/CoodleSurveyBasicInfo.js";
import CoodleSurveyHeader from "./CoodleSurvey/CoodleSurveyHeader.js";
import { formatDate, numberPadding } from "../../../../helpers/DateHelpers.js";
import CoodleApi from "../../../../api/factory/coodle.js";
export default {
name: "CoodleSurvey",
components: {
CoodleSurveyVotingTable,
CoodleSurveyCalendar,
CoodleSurveyParticipants,
CoodleSurveyDurationSelector,
CoodleSurveyBasicInfo,
CoodleSurveyHeader,
},
props: {
survey: { type: Object | null },
authInfo: { type: Object | null },
authExternalParticipantId: { type: Number | null },
isExternal: { type: Boolean, default: false },
},
emits: [
"surveyCreationCanceled",
"surveyCreated",
"surveyUpdated",
"surveyCanceled",
"surveyCompleted",
],
data() {
return {
surveyFormData: null,
participantScheduleColors: [
{
uid: null,
color: "#FF0000",
},
{
uid: null,
color: "#00FF00",
},
{
uid: null,
color: "#0000FF",
},
{
uid: null,
color: "#FF00FF",
},
{
uid: null,
color: "#00FFFF",
},
{
uid: null,
color: "#FFFF00",
},
{
uid: null,
color: "#FFA500",
},
{
uid: null,
color: "#800080",
},
{
uid: null,
color: "#228B22",
},
{
uid: null,
color: "#B87333",
},
],
isEditInProgress: false,
weekdayPhrases: [
"global/monday",
"global/tuesday",
"global/wednesday",
"global/thursday",
"global/friday",
"global/saturday",
"global/sunday",
],
monthPhrases: [
"global/january",
"global/february",
"global/march",
"global/april",
"global/may",
"global/june",
"global/july",
"global/august",
"global/september",
"global/october",
"global/november",
"global/december",
],
};
},
computed: {
isDarkMode() {
return this.$theme.theme_name.value == "dark";
},
parsedSelectedTimeslot() {
if (!this.$props.survey?.selectedTimeslotId) {
return null;
}
const selectedTimeslot = this.$props.survey.timeslots.find(
(timeslot) =>
timeslot.id === this.$props.survey.selectedTimeslotId,
);
if (!selectedTimeslot) {
return null;
}
return this.parseTimeslotForDisplay(selectedTimeslot);
},
parsedTimeslotsForVotingTable() {
return (
this.$props.survey?.timeslots
.sort((timeslotA, timeslotB) => {
return timeslotA.startsAt < timeslotB.startsAt ? -1 : 1;
})
.map((timeslot) =>
this.parseTimeslotForDisplay(timeslot),
) ?? []
);
},
weekdays() {
return this.weekdayPhrases.map((phrase) => this.$p.t(phrase));
},
months() {
return this.monthPhrases.map((phrase) => this.$p.t(phrase));
},
},
watch: {
survey: {
async handler() {
this.setSurveyFormData();
if (!this.$props.survey?.id) {
this.isEditInProgress = true;
} else {
this.isEditInProgress = false;
}
},
deep: true,
},
isEditInProgress() {
if (!this.isEditInProgress) {
this.setSurveyFormData();
}
},
},
methods: {
setSurveyFormData() {
if (this.$props.survey) {
this.surveyFormData = { ...this.$props.survey };
} else {
let defaultEndsAtDate = new Date();
defaultEndsAtDate.setDate(defaultEndsAtDate.getDate() + 7);
this.surveyFormData = {
id: null,
creator: null,
title: "",
description: "",
timeslotDuration: 60,
maxSelections: 1,
areParticipantsAnonymized: false,
areSelectionsAnonymized: false,
selectedTimeslot: null,
endsAt: defaultEndsAtDate.toISOString().slice(0, 10),
completedAt: null,
canceledAt: null,
updatedAt: null,
createdAt: null,
timeslots: [],
participants: [],
externalParticipants: [],
};
}
this.surveyFormData.participants.forEach((participant) => {
participant.isCalendarShown = false;
});
this.participantScheduleColors.forEach((participantColor) => {
participantColor.uid = null;
});
},
parseTimeslotForDisplay(timeslot) {
const timeslotStartsAt = new Date(timeslot.startsAt);
let timeslotEndsAt = new Date(timeslot.startsAt);
timeslotEndsAt.setMinutes(
timeslotEndsAt.getMinutes() +
(this.$props.survey?.timeslotDuration ?? 0),
);
return {
id: timeslot.id,
weekday: this.weekdays[timeslotStartsAt.getDay()],
date: numberPadding(timeslotStartsAt.getDate()),
month: this.months[timeslotStartsAt.getMonth()],
year: timeslotStartsAt.getFullYear().toString(),
fullDate: formatDate(timeslot.startsAt + " UTC"),
startTime:
numberPadding(timeslotStartsAt.getHours()) +
":" +
numberPadding(timeslotStartsAt.getMinutes()),
endTime:
numberPadding(timeslotEndsAt.getHours()) +
":" +
numberPadding(timeslotEndsAt.getMinutes()),
startsAt: timeslotStartsAt,
endsAt: timeslotEndsAt,
};
},
cancelEditForm() {
this.isEditInProgress = false;
if (!this.$props.survey?.id) {
this.$emit("surveyCreationCanceled");
}
},
async submitForm() {
if (
!this.surveyFormData.participants.length &&
!this.surveyFormData.externalParticipants.length
) {
const shouldProceedWithoutParticipants =
await this.$fhcAlert.confirm({
header: this.$p.t("coodle/warning"),
message: this.$p.t("coodle/no_participants_added"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!shouldProceedWithoutParticipants) return;
}
if (!this.surveyFormData.timeslots.length) {
const shouldProceedWithoutTimeslots =
await this.$fhcAlert.confirm({
header: this.$p.t("coodle/warning"),
message: this.$p.t("coodle/no_timeslots_added"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!shouldProceedWithoutTimeslots) return;
}
let surveyData = this.formatOutgoingSurveyData(this.surveyFormData);
if (this.surveyFormData.id) {
this.updateSurvey(surveyData);
} else {
this.createSurvey(surveyData);
}
},
formatOutgoingSurveyData(surveyFormData) {
let timeslotDuration = surveyFormData.timeslotDuration;
if (!timeslotDuration || timeslotDuration < 5) {
timeslotDuration = 5;
} else if (timeslotDuration % 5 !== 0) {
timeslotDuration = timeslotDuration - (timeslotDuration % 5);
}
let maxSelections = surveyFormData.maxSelections;
if (maxSelections < 1) {
maxSelections = 1;
}
const participants = surveyFormData.participants.map(
(participant) => {
return {
uid: participant.uid,
};
},
);
return {
id: surveyFormData.id,
title: surveyFormData.title.trim(),
description: surveyFormData.description.trim(),
timeslotDuration,
maxSelections,
areParticipantsAnonymized:
surveyFormData.areParticipantsAnonymized,
areSelectionsAnonymized: surveyFormData.areSelectionsAnonymized,
endsAt: surveyFormData.endsAt,
timeslots: surveyFormData.timeslots,
participants,
externalParticipants: surveyFormData.externalParticipants,
};
},
async createSurvey(surveyData) {
const shouldInformParticipants = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/inform_participants"),
message: this.$p.t("coodle/email_participants_created"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
const surveyCreationResponse = await this.$api.call(
CoodleApi.createSurvey(surveyData),
);
if (surveyCreationResponse.meta.status === "success") {
this.$emit("surveyCreated", {
surveyId: surveyCreationResponse.data,
shouldInformParticipants,
});
}
},
async updateSurvey(surveyData) {
const shouldInformParticipants = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/inform_participants"),
message: this.$p.t("coodle/email_participants_updated"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
const surveyUpdateResponse = await this.$api.call(
CoodleApi.updateSurvey(surveyData.id, surveyData),
);
if (surveyUpdateResponse.meta.status === "success") {
this.$emit("surveyUpdated", {
surveyId: surveyData.id,
shouldInformParticipants,
});
}
},
},
created() {
this.setSurveyFormData();
if (!this.$props.survey?.id) {
this.isEditInProgress = true;
}
},
template: /*html*/ `
<div class="card mb-4" style="min-height:100%">
<div class="card-header">
<coodle-survey-header
@editSurvey="isEditInProgress = true"
@surveyCanceled="
$emit('surveyCanceled', {
surveyId: $props.survey.id,
shouldInformParticipants: $event.shouldInformParticipants
})
"
:survey="$props.survey"
:isEditInProgress="isEditInProgress"
:authUid="$props.authInfo?.uid"
/>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
<div v-if="isEditInProgress" class="d-flex flex-row justify-content-between">
<span class="fst-italic">
{{ $p.t("coodle/required_inputs") }}
</span>
<div class="d-flex flex-row gap-2 justify-content-end align-items-start px-0">
<div>
<div
@click="cancelEditForm()"
:class="isDarkMode ? 'btn-outline-light' : 'btn-outline-dark'"
class="btn text-nowrap"
>
{{ $p.t("ui/abbrechen") }}
</div>
</div>
<div>
<div
@click="submitForm()"
:class="isDarkMode ? 'btn-light' : 'btn-dark'"
class="btn text-nowrap"
>
{{ $p.t("global/speichern") }}
</div>
</div>
</div>
</div>
<coodle-survey-basic-info
v-model:surveyFormDataModelValue="surveyFormData"
:survey="$props.survey"
:parsedSelectedTimeslot="parsedSelectedTimeslot"
:isEditInProgress="isEditInProgress"
/>
<hr>
<div v-if="!isEditInProgress && $props.survey" class="d-flex flex-column gap-2">
<coodle-survey-voting-table
@selectionSubmitted="$emit('surveyUpdated', {surveyId: $props.survey.id})"
@surveyCompleted="
$emit('surveyCompleted',{
surveyId: $props.survey.id,
shouldInformParticipants: $event.shouldInformParticipants,
selectedRoomId: $event.selectedRoomId
})
"
:authUid="$props.authInfo?.uid"
:timeslots="parsedTimeslotsForVotingTable"
:survey="$props.survey"
:authExternalParticipantId="$props.authExternalParticipantId"
/>
</div>
<div v-else-if="!$props.isExternal" class="d-flex flex-column gap-3">
<div class="row">
<div class="col-12 col-xxl-4 pb-3">
<coodle-survey-participants
v-if="surveyFormData?.participants"
v-model:participantsModelValue="surveyFormData.participants"
v-model:externalParticipantsModelValue="surveyFormData.externalParticipants"
v-model:participantScheduleColorsModelValue="participantScheduleColors"
:authInfo="authInfo"
/>
</div>
<div class="col-12 col-xxl-8">
<div class="d-flex flex-column gap-4">
<coodle-survey-duration-selector
v-if="surveyFormData?.timeslotDuration"
v-model:durationModelValue="surveyFormData.timeslotDuration"
:survey="$props.survey"
/>
<coodle-survey-calendar
v-model:timeslotsModelValue="surveyFormData.timeslots"
:survey="$props.survey"
:timeslotDuration="surveyFormData.timeslotDuration"
:surveyFormDataParticipants="surveyFormData.participants"
:participantScheduleColors="participantScheduleColors"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>`,
};
@@ -0,0 +1,277 @@
import { formatDate } from "../../../../../helpers/DateHelpers.js";
export default {
name: "CoodleSurveyBasicInfo",
props: {
survey: Object | null,
surveyFormDataModelValue: Object | null,
parsedSelectedTimeslot: Object | null,
isEditInProgress: Boolean,
},
emits: ["update:surveyFormDataModelValue"],
computed: {
formattedSurveyDescription() {
return this.$props.survey?.description?.replaceAll("\n", "<br>");
},
formattedSurveyTimeslotDuration() {
if (this.isNewSurvey) {
return "";
}
const minutes = this.$props.survey.timeslotDuration % 60;
const hours = (this.$props.survey.timeslotDuration - minutes) / 60;
let formattedDuration = hours ? hours + " hr " : "";
formattedDuration += minutes + " min";
return formattedDuration;
},
formattedSurveyEndsAt() {
if (this.$props.survey?.endsAt) {
return formatDate(this.$props.survey.endsAt);
} else {
return "";
}
},
formattedSurveyCreatedAt() {
if (this.$props.survey?.createdAt) {
return formatDate(this.$props.survey.createdAt);
} else {
return "";
}
},
formattedSurveyUpdatedAt() {
if (this.$props.survey?.updatedAt) {
return formatDate(this.$props.survey.updatedAt);
} else {
return "";
}
},
formattedSurveyCanceledAt() {
if (this.$props.survey?.canceledAt) {
return formatDate(this.$props.survey.canceledAt);
} else {
return "";
}
},
formattedSurveyCompletedAt() {
if (this.$props.survey?.completedAt) {
return formatDate(this.$props.survey.completedAt);
} else {
return "";
}
},
isNewSurvey() {
return !this.$props.survey?.id;
},
surveyFormData: {
get() {
return this.$props.surveyFormDataModelValue;
},
set(newValue) {
this.$emit("update:surveyFormDataModelValue", newValue);
},
},
},
methods: {
showCreatorProfile() {
if (this.isNewSurvey || !this.survey?.creator?.uid) {
return;
}
const creatorHref = this.$router.resolve({
name: "ProfilView",
params: { uid: this.survey?.creator?.uid },
}).href;
window.open(creatorHref, "_blank");
},
},
template: /*html*/ `
<div class="d-flex flex-column gap-3">
<template v-if="!$props.isEditInProgress">
<span v-if="$props.survey?.completedAt" class="fst-italic">
{{
$p.t("coodle/survey_completed_info").replace(
'(((completedAt)))',
formattedSurveyCompletedAt
)
}}
<span v-if="$props.survey?.selectedTimeslotId">
{{
$p.t("coodle/selected_timeslot_info").replace(
"(((timeslot)))",
parsedSelectedTimeslot?.weekday +
" " +
parsedSelectedTimeslot?.fullDate +
" " +
parsedSelectedTimeslot?.startTime +
"-" +
parsedSelectedTimeslot?.endTime
)
}}
</span>
<span v-else>{{ $p.t("coodle/no_selected_timeslot_info") }}</span>
</span>
<span v-else-if="$props.survey?.canceledAt" class="fst-italic">
{{
$p.t("coodle/survey_canceled_info").replace(
'(((canceledAt)))',
formattedSurveyCanceledAt
)
}}
</span>
<div class="d-flex flex-row gap-3 flex-wrap">
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-user fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/created_by") + ": " }}</span>
{{ this.$props.survey?.creator?.name }}
<span
v-if="this.$props.survey?.creator?.uid"
@click="showCreatorProfile()"
type="button"
class="fhc-primary-color"
>
<i class="fa-solid fa-up-right-from-square"></i>
</span>
{{ " " + $p.t("ui/am") + " " + formattedSurveyCreatedAt}}
</span>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-calendar fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/planned_end_date") + ": " }}</span>
{{ formattedSurveyEndsAt }}
</span>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-clock fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/timeslot_duration") + ": " }}</span>
{{ formattedSurveyTimeslotDuration }}
</span>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-calendar-check fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/max_selectable_timeslots") + ": " }}</span>
{{ $props.survey?.maxSelections}}
</span>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-user-secret fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/anon_participants") + ": " }}</span>
{{ $props.survey?.areParticipantsAnonymized ? $p.t("coodle/yes") : $p.t("coodle/no")}}
</span>
</div>
<div class="d-flex flex-row">
<div class="d-flex flex-row justify-content-center align-items-center" style="width: 30px;">
<i class="fa-solid fa-eye-slash fa-lg"></i>
</div>
<span>
<span class="fw-bold">{{ $p.t("coodle/anon_votes") + ": " }}</span>
{{ $props.survey?.areSelectionsAnonymized ? $p.t("coodle/yes") : $p.t("coodle/no") }}
</span>
</div>
</div>
<span
v-if="$props.survey?.description?.length"
class="text-wrap fst-italic"
v-html="formattedSurveyDescription"
></span>
</template>
<template v-else>
<div class="row">
<div class="d-flex flex-column gap-2 col-12 col-md-6 pb-2">
<div class="d-flex flex-column gap-1">
<label for="surveyTitleInput" class="fw-bold">{{ "* " + $p.t("coodle/title") }}</label>
<input v-model="surveyFormData.title" id="surveyTitleInput" type="text" maxlength="255" />
</div>
<div class="d-flex flex-column gap-1">
<label for="surveyDescriptionInput" class="fw-bold">{{ $p.t("coodle/description") }}</label>
<textarea v-model="surveyFormData.description" rows="4" class="flex-grow-1" maxlength="1000" />
</div>
</div>
<div class="col-12 col-md-6">
<div class="row">
<div class="col-12 col-xxl-6 pb-2">
<div class="d-flex flex-column gap-1">
<label
for="surveyMaxSelectionsInput"
class="fw-bold text-wrap"
>
{{ $p.t("coodle/max_selectable_timeslots") }}
</label>
<input
v-model="surveyFormData.maxSelections"
id="surveyMaxSelectionsInput"
type="number"
min="1"
class="flex-grow-1"
/>
</div>
</div>
<div class="col-12 col-xxl-6 pb-2">
<div class="d-flex flex-column gap-1">
<label
for="surveyEndsAtInput"
class="fw-bold text-wrap"
>
{{ "* " + $p.t("coodle/planned_end_date") }}
</label>
<input
v-model="surveyFormData.endsAt"
id="surveyEndsAtInput"
type="date"
class="flex-grow-1"
/>
</div>
</div>
<div class="col-12">
<div class="row">
<div class="col-12 col-lg-6 d-flex flex-row align-items-center gap-2 pb-2">
<label
for="surveyAreParticipantsAnonymizedInput"
class="fw-bold text-wrap"
>
{{ $p.t("coodle/anon_participants") + "?" }}
</label>
<input
v-model="surveyFormData.areParticipantsAnonymized"
id="surveyAreParticipantsAnonymizedInput"
type="checkbox"
style="min-height:15px;min-width:15px;"
/>
</div>
<div class="col-12 col-lg-6 d-flex flex-row align-items-center gap-2 pb-2">
<label
for="surveyAreSelectionsAnonymizedInput"
class="fw-bold text-wrap"
>
{{ $p.t("coodle/anon_votes") + "?" }}
</label>
<input
v-model="surveyFormData.areSelectionsAnonymized"
id="surveyAreSelectionsAnonymizedInput"
type="checkbox"
style="min-height:15px;min-width:15px;"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
`,
};
@@ -0,0 +1,426 @@
import CoodleSurveyCalendarTimeslotCard from "./CoodleSurveyCalendar/CoodleSurveyCalendarTimeslotCard.js";
import FhcCalendar from "../../../../Calendar/Base.js";
import ModeWeek from "../../../../Calendar/Mode/Week.js";
import ModeDay from "../../../../Calendar/Mode/Day.js";
import FreeBusyApi from "../../../../../api/factory/freeBusy.js";
import { useRenderers } from "../../../../../composables/Renderers.js";
import { numberPadding } from "../../../../../helpers/DateHelpers.js";
export default {
name: "CoodleSurveyCalendar",
components: { CoodleSurveyCalendarTimeslotCard, FhcCalendar },
props: {
timeslotsModelValue: Array | null,
survey: Object | null,
surveyFormDataParticipants: Array,
participantScheduleColors: Array,
timeslotDuration: Number | null,
renderers: [],
},
emits: ["update:timeslotsModelValue"],
setup() {
const { renderers } = useRenderers();
return {
renderers,
};
},
inject: {
isMobile: "isMobile",
},
data() {
return {
mode: "Week",
modeOptions: {
week: {
collapseEmptyDays: false,
},
},
timeslotCalendarEvents: [],
participantSchedules: {},
isTimeslotCardEditInProgress: false,
renderers: [],
};
},
computed: {
timezone() {
return FHC_JS_DATA_STORAGE_OBJECT.timezone;
},
backgrounds() {
let now = luxon.DateTime.now().setZone(this.timezone);
return [
{
class: "background-past",
end: now,
label: now.startOf("minute").toISOTime({
suppressSeconds: true,
includeOffset: false,
}),
},
];
},
events() {
let events = this.timeslotCalendarEvents;
const participantsWithDisplayedSchedules =
this.$props.surveyFormDataParticipants.filter(
(participant) => participant.isCalendarShown,
);
participantsWithDisplayedSchedules.forEach((participant) => {
let participantEvents =
this.participantSchedules[participant.uid] ?? [];
participantEvents = participantEvents.map((event) => {
event.farbe =
this.$props.participantScheduleColors
.find(
(participantColor) =>
participantColor.uid === participant.uid,
)
?.color.slice(1) ?? "DDDDDD";
return event;
});
events = events.concat(participantEvents);
});
return events;
},
roundedTimeslotDuration() {
if (!this.$props.timeslotDuration) {
return 5;
}
return (
this.$props.timeslotDuration -
(this.$props.timeslotDuration % 5)
);
},
timeslots: {
get() {
return this.timeslotsModelValue;
},
set(value) {
this.$emit("update:timeslotsModelValue", value);
},
},
sortedTimeslotCalendarEvents() {
return this.timeslotCalendarEvents.sort(
(timeslotCalendarEventA, timeslotCalendarEventB) => {
let timeslotAStartsAt =
timeslotCalendarEventA.datum +
timeslotCalendarEventA.beginn;
let timeslotBStartsAt =
timeslotCalendarEventB.datum +
timeslotCalendarEventB.beginn;
if (timeslotAStartsAt > timeslotBStartsAt) {
return 1;
} else {
return -1;
}
},
);
},
modes() {
let modes = {
day: Vue.markRaw(ModeDay),
};
if (!this.isMobile) {
modes.week = Vue.markRaw(ModeWeek);
}
return modes;
},
},
watch: {
timeslotDuration: {
handler() {
this.updateDurationOfExistingTimeslotCalendarEvents();
},
},
timeslotCalendarEvents: {
handler() {
let oldTimeslotStartTimes =
this.$props.survey?.timeslots.map(
(timeslot) => timeslot.startsAt,
) ?? [];
let updatedTimeslotStartTimes = this.timeslotCalendarEvents.map(
(timeslotCalendarEvent) =>
timeslotCalendarEvent.datum +
" " +
timeslotCalendarEvent.beginn,
);
let updatedTimeslots =
this.$props.survey?.timeslots.filter((timeslot) =>
updatedTimeslotStartTimes.includes(timeslot.startsAt),
) ?? [];
updatedTimeslotStartTimes.forEach((startTime) => {
if (
!updatedTimeslots.some(
(timeslot) => timeslot.startsAt === startTime,
)
) {
updatedTimeslots.push({
id: null,
startsAt: startTime,
});
}
});
this.timeslots = updatedTimeslots;
},
deep: true,
},
survey: {
handler() {
this.setTimeslotCalendarEvents();
this.participantSchedules = {};
},
deep: true,
},
surveyFormDataParticipants: {
handler() {
this.updateDisplayedSchedules();
},
deep: true,
},
},
methods: {
setTimeslotCalendarEvents() {
this.timeslotCalendarEvents =
this.$props.survey?.timeslots.map((timeslot) => {
let startDate = new Date(timeslot.startsAt);
return this.generateTimeslotCalendarEvent(startDate);
}) ?? [];
},
addTimeslotOnCalendarClick(event) {
if (this.timeslotCalendarEvents.length >= 50) {
return;
}
const elementRectangle = event.event.target.getBoundingClientRect();
const relativeClickLocation = event.event.y - elementRectangle.top;
let minutes =
Math.floor(
(relativeClickLocation / elementRectangle.height) * 12,
) * 5;
if (minutes < 0 || minutes > 55) {
minutes = 0;
}
let hours = event.params?.part?.values?.hours ?? 12;
if (hours < 7 || hours > 22) {
hours = 12;
}
let start = new Date(event.params.date.ts);
start.setHours(hours);
start.setMinutes(minutes);
let newTimeslotCalendarEvent =
this.generateTimeslotCalendarEvent(start);
if (
this.timeslotCalendarEvents.some(
(timeslotCalendarEvent) =>
timeslotCalendarEvent.isostart ===
newTimeslotCalendarEvent.isostart,
)
) {
return;
}
this.timeslotCalendarEvents.push(newTimeslotCalendarEvent);
},
generateTimeslotCalendarEvent(start) {
let end = new Date(start.getTime());
end.setMinutes(end.getMinutes() + this.roundedTimeslotDuration);
return {
type: "coodle",
datum: start.toISOString().slice(0, 10),
beginn:
numberPadding(start.getHours()) +
":" +
numberPadding(start.getMinutes()) +
":00",
ende:
numberPadding(end.getHours()) +
":" +
numberPadding(end.getMinutes()) +
":00",
isostart: start.toISOString(),
isoend: end.toISOString(),
farbe: "DDDDDD",
};
},
updateDurationOfExistingTimeslotCalendarEvents() {
this.timeslotCalendarEvents = this.timeslotCalendarEvents.map(
(timeslotCalendarEvent) => {
let start = new Date(timeslotCalendarEvent.isostart);
return this.generateTimeslotCalendarEvent(start);
},
);
},
createTimeslotFromCard(startDate) {
if (
this.timeslotCalendarEvents.some(
(existingTimeslotCalendarEvent) =>
existingTimeslotCalendarEvent.isostart ===
startDate.toISOString(),
)
) {
this.$fhcAlert.alertError(this.$p.t("coodle/duplicate_creation_not_allowed"));
return;
}
this.timeslotCalendarEvents.push(
this.generateTimeslotCalendarEvent(startDate),
);
this.isTimeslotCardEditInProgress = false;
},
updateTimeslotFromCard(timeslotCalendarEvent, newStartDate) {
if (
this.timeslotCalendarEvents.some(
(existingTimeslotCalendarEvent) =>
existingTimeslotCalendarEvent.isostart ===
newStartDate.toISOString(),
)
) {
this.$fhcAlert.alertError(this.$p.t("coodle/duplicate_creation_not_allowed"));
return;
}
this.timeslotCalendarEvents = this.timeslotCalendarEvents.map(
(existingTimeslotCalendarEvent) => {
if (
existingTimeslotCalendarEvent.isostart !==
timeslotCalendarEvent.isostart
) {
return existingTimeslotCalendarEvent;
}
return this.generateTimeslotCalendarEvent(newStartDate);
},
);
this.isTimeslotCardEditInProgress = false;
},
deleteTimeslot(timeslotCalendarEvent) {
this.timeslotCalendarEvents = this.timeslotCalendarEvents.filter(
(existingTimeslotCalendarEvent) => {
return (
existingTimeslotCalendarEvent.isostart !==
timeslotCalendarEvent.isostart
);
},
);
this.isTimeslotCardEditInProgress = false;
},
getEventStyle(event) {
if (!event.farbe) return undefined;
return "--event-bg:#" + event.farbe;
},
updateDisplayedSchedules() {
let participantsWithDisplayedSchedules =
this.$props.surveyFormDataParticipants.filter(
(participant) => participant.isCalendarShown,
);
participantsWithDisplayedSchedules.forEach((participant) => {
if (!(participant.uid in this.participantSchedules)) {
this.fetchFreeBusySchedule(participant.uid);
}
});
},
async fetchFreeBusySchedule(uid) {
this.participantSchedules[uid] = null;
const freeBusyScheduleResponse = await this.$api.call(
FreeBusyApi.getFreeBusySchedule(uid, this.$props.survey?.id),
);
this.participantSchedules[uid] = freeBusyScheduleResponse.data.map(
(event) => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
return {
datum: event.start.split(" ")[0],
beginn: event.start.split(" ")[1],
ende: event.end.split(" ")[1],
isostart: startDate.toISOString(),
isoend: endDate.toISOString(),
type: "freeBusy",
};
},
);
},
},
created() {
this.setTimeslotCalendarEvents();
},
template: /*html*/ `
<div id="coodleCalendar" class="d-flex flex-column gap-3">
<div class="d-flex flex-column">
<span class="fw-bold">{{ $p.t("coodle/timeslot_selection") }}</span>
<span class="fst-italic">{{ $p.t("coodle/timeslot_adding_options") }}</span>
</div>
<div style="height:800px;">
<fhc-calendar
v-if="renderers"
@emptyClicked="addTimeslotOnCalendarClick($event)"
:ref="'coodleCalendar'"
:timezone="timezone"
:modes="modes"
:modeOptions="modeOptions"
:mode="!isMobile ? 'Week' : 'Day'"
:timeGrid="null"
:locale="$p.user_locale.value"
:events="events"
:backgrounds="backgrounds"
:draggableEvents="true"
:droppableEvents="true"
:onDrop="true"
:isAutoScrollEnabled="false"
:showBtns="!isMobile"
>
<template v-slot="{ event, mode }">
<div
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:type="mode == 'day' ? 'button' : undefined"
:style="getEventStyle(event)"
>
<component
v-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<component
v-else
@click.stop=""
@deleteCoodleTimeslot="deleteTimeslot($event.timeslot)"
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
</fhc-calendar>
</div>
<div class="row">
<div v-for="timeslot in sortedTimeslotCalendarEvents.concat([null])" class="col-12 col-md-6 col-xl-4">
<coodle-survey-calendar-timeslot-card
@createTimeslot="createTimeslotFromCard($event.startDate)"
@updateTimeslot="updateTimeslotFromCard(timeslot, $event.newStartDate)"
@deleteTimeslot="deleteTimeslot(timeslot)"
@setIsTimeslotCardEditInProgress="isTimeslotCardEditInProgress = $event.value"
:timeslot="timeslot"
:isTimeslotCardEditInProgress="isTimeslotCardEditInProgress"
/>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,253 @@
import { numberPadding } from "../../../../../../helpers/DateHelpers.js";
export default {
name: "CoodleSurveyCalendarTimeslotCard",
components: {
VueDatePicker,
},
props: {
timeslot: Object | null,
isTimeslotCardEditInProgress: Boolean,
},
emits: [
"deleteTimeslot",
"updateTimeslot",
"setIsTimeslotCardEditInProgress",
],
data() {
return {
isEditInProgress: false,
dateInput: null,
startTimeInput: null,
};
},
computed: {
formattedDate() {
let dateFragments = this.$props.timeslot.datum.split("-");
return dateFragments[2] + "." + dateFragments[1] + ".";
},
formattedStartEnd() {
return (
this.$props.timeslot?.beginn?.slice(0, 5) +
"-" +
this.$props.timeslot?.ende?.slice(0, 5)
);
},
},
watch: {
timeslot: {
handler() {
this.isEditInProgress = false;
this.setInputs();
},
deep: true,
},
isEditInProgress() {
this.setInputs();
},
isTimeslotCardEditInProgress() {
this.isEditInProgress = false;
},
},
methods: {
setInputs() {
const tomorrow = new Date(
new Date().getTime() + 24 * 60 * 60 * 1000,
);
this.dateInput =
this.$props.timeslot?.datum ??
tomorrow.toISOString().slice(0, 10);
this.startTimeInput =
this.$props.timeslot?.beginn?.slice(0, 5) ?? "12:00";
},
async startEdit() {
if (this.$props.isTimeslotCardEditInProgress) {
this.$emit("setIsTimeslotCardEditInProgress", { value: false });
}
await this.$nextTick();
this.$emit("setIsTimeslotCardEditInProgress", { value: true });
await this.$nextTick();
this.isEditInProgress = true;
},
cancelEdit() {
this.isEditInProgress = false;
this.$emit("setIsTimeslotCardEditInProgress", { value: false });
},
submitForm() {
if (!this.dateInput || !this.startTimeInput) {
this.$fhcAlert.alertError("Check your inputs!");
return;
}
if (
this.dateInput === this.$props.timeslot?.datum &&
this.startTimeInput ===
this.$props.timeslot?.beginn?.slice(0, 5)
) {
this.isEditInProgress = false;
return;
}
if (
this.startTimeInput < this.minStartTimeInput ||
this.startTimeInput > this.maxStartTimeInput
) {
this.$fhcAlert.alertError(
this.$p.t("coodle/timeslot_start_limits"),
);
return;
}
let minutes = parseInt(this.startTimeInput.slice(3));
if (minutes % 5) {
minutes = minutes - (minutes % 5);
this.startTimeInput =
this.startTimeInput.slice(0, 3) + numberPadding(minutes);
}
let startDate = new Date(
this.dateInput + " " + this.startTimeInput,
);
if (this.$props.timeslot) {
this.$emit("updateTimeslot", { newStartDate: startDate });
} else {
this.$emit("createTimeslot", { startDate });
}
},
updateDateInput(value) {
let date = new Date(value);
this.dateInput = date.toISOString().slice(0, 10);
},
getDateInputPreview() {
if (!this.dateInput?.length) return "";
const day = this.dateInput.slice(8);
const month = this.dateInput.slice(5, 7);
const year = this.dateInput.slice(0, 4);
return day + "." + month + "." + year;
},
updateStartTimeInput(value) {
this.startTimeInput =
numberPadding(value.hours) + ":" + numberPadding(value.minutes);
},
getStartTimeInputPreview() {
if (!this.startTimeInput?.length) return "";
const hours = this.startTimeInput.slice(0, 2);
const minutes = this.startTimeInput.slice(3, 5);
return hours + ":" + minutes;
},
getInitialStartTimeValue() {
if (!this.startTimeInput?.length) {
return {
hours: 12,
minutes: 0,
};
}
return {
hours: parseInt(this.startTimeInput.split(":")[0]),
minutes: parseInt(this.startTimeInput.split(":")[1]),
};
},
},
created() {
this.setInputs();
},
template: /*html*/ `
<div class="w-100 p-2" style="height: 120px;">
<div class="coodleCalendarTimeslotCard h-100 d-flex flex-row justify-content-between px-3 py-2 border border-2 rounded-2">
<template v-if="$props.timeslot || isEditInProgress">
<div
v-if="!isEditInProgress"
class="d-flex flex-column gap-2 justify-content-center align-items-center flex-grow-1"
>
<span class="fs-5 fw-bold">{{ formattedDate }}</span>
<span>{{ formattedStartEnd }}</span>
</div>
<div v-else class="d-flex flex-column gap-2 flex-grow-1 justify-content-center pe-3">
<div class="d-flex flex-row align-items-center gap-2">
<label :for="'dateInput-' + $props.timeslot?.isostart" class="fw-bold">{{ $p.t("coodle/date") + ": " }}</label>
<vue-date-picker
@update:model-value="updateDateInput($event)"
:model-value="dateInput"
:format="() => getDateInputPreview()"
:text-input="true"
:clearable="false"
:enable-time-picker="false"
:now-button-label="$p.t('calendar/today')"
:week-num-name="$p.t('calendar/kw')"
auto-apply
six-weeks
teleport
/>
</div>
<div class="d-flex flex-row gap-2">
<label :for="'startTimeInput-' + $props.timeslot?.isostart" class="fw-bold">{{ $p.t("coodle/starts_at") + ": " }}</label>
<vue-date-picker
@update:model-value="updateStartTimeInput($event)"
:model-value="getInitialStartTimeValue()"
:format="() => getStartTimeInputPreview()"
:text-input="true"
:clearable="false"
:minutes-increment="5"
:min-time="{ hours: 7, minutes: 0 }"
:max-time="{ hours: 22, minutes: 55 }"
time-picker
teleport
/>
</div>
</div>
<div class="d-flex flex-row align-items-start justify-content-end">
<template v-if="!isEditInProgress">
<div
@click="startEdit()"
:title="$p.t('coodle/edit')"
type="button"
class="p-1 fhcPrimaryHover"
>
<i class="fa-solid fa-pen-to-square fa-lg"></i>
</div>
<div
@click="$emit('deleteTimeslot')"
:title="$p.t('ui/loeschen')"
type="button"
class="p-1 fhcPrimaryHover"
>
<i class="fa-solid fa-trash-can fa-lg"></i>
</div>
</template>
<template v-else>
<div
@click="cancelEdit()"
:title="$p.t('ui/abbrechen')"
type="button"
class="p-1 fhcPrimaryHover"
>
<i class="fa-regular fa-circle-left fa-lg"></i>
</div>
<div
@click="submitForm()"
:title="$p.t('global/speichern')"
type="button"
class="p-1 fhcPrimaryHover"
>
<i class="fa-solid fa-floppy-disk fa-lg"></i>
</div>
</template>
</div>
</template>
<div v-else class="flex-grow-1 d-flex flex-row justify-content-center align-items-center">
<i
@click="startEdit()"
:title="$p.t('ui/hinzufugen')"
type="button"
class="fhcPrimaryHover fa-solid fa-circle-plus fa-2xl"
></i>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,92 @@
export default {
name: "CoodleSurveyDurationSelector",
props: {
durationModelValue: Number | null,
survey: Object,
},
emits: ["update:durationModelValue"],
data() {
return {
appointmentDurationPresets: [15, 30, 45, 60, 90, 120],
selectedDurationPreset: null,
durationCustomInput: null,
};
},
computed: {
duration: {
get() {
return this.$props.durationModelValue;
},
set(newValue) {
this.$emit("update:durationModelValue", newValue);
},
},
},
watch: {
durationCustomInput() {
if (this.durationCustomInput) {
this.duration = this.durationCustomInput;
this.selectedDurationPreset = null;
}
},
survey: {
handler() {
this.initializeInputs();
},
deep: true,
},
},
methods: {
selectAppointmentDurationPreset(preset) {
this.selectedDurationPreset = preset;
this.duration = preset;
this.durationCustomInput = null;
},
initializeInputs() {
this.selectedDurationPreset = null;
this.durationCustomInput = null;
if (this.appointmentDurationPresets.includes(this.duration)) {
this.selectedDurationPreset = this.duration;
} else {
this.durationCustomInput = this.duration;
}
},
},
created() {
this.initializeInputs();
},
template: /*html*/ `
<div class="d-flex flex-column gap-2">
<span class="fw-bold">{{ "* " + $p.t("coodle/timeslot_duration_in_minutes") }}</span>
<div class="overflow-x-auto">
<div class="d-flex flex-row align-items-center gap-2">
<div
v-for="durationPreset in appointmentDurationPresets"
@click="selectAppointmentDurationPreset(durationPreset)"
type="button"
:class="{'fhc-primary-border-color': selectedDurationPreset === durationPreset}"
class="border border-2 d-flex justify-content-center align-items-center"
style="min-width:60px;height:40px;"
>
<span>{{ durationPreset }}</span>
</div>
<span class="text-nowrap ps-2">{{ $p.t("coodle/custom_duration") + ": " }}</span>
<div
:class="{'fhc-primary-border-color': durationCustomInput}"
class="border border-2 d-flex justify-content-center align-items-center px-1"
style="width:60px;height:40px;"
>
<input
v-model="durationCustomInput"
type="number"
min="5"
step="5"
class="border-0"
style="min-width:0; outline: none; background: none;"
/>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,146 @@
import CoodleApi from "../../../../../api/factory/coodle.js";
export default {
name: "CoodleSurveyHeader",
props: {
survey: Object | null,
isEditInProgress: Boolean,
authUid: String | null,
},
emits: ["editSurvey", "surveyCanceled"],
computed: {
headerTitle() {
if (this.$props.survey?.id && !this.$props.isEditInProgress) {
return this.$props.survey.title;
} else if (this.$props.survey?.id && this.$props.isEditInProgress) {
return this.$p.t("coodle/edit_survey");
} else {
return this.$p.t("coodle/new_survey");
}
},
isSurveyActive() {
return (
!this.$props.survey.completedAt &&
!this.$props.survey.canceledAt
);
},
isAuthUserSurveyCreator() {
return (
this.$props.authUid &&
this.$props.authUid === this.$props.survey?.creator?.uid
);
},
},
methods: {
async cancelSurvey() {
if (!this.$props.survey?.id) return;
const cancellationConfirmationMessage = this.$p
.t("coodle/cancel_survey_confirmation_message")
.replace("(((surveyTitle)))", this.$props.survey?.title);
const cancellationConfirmation = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/cancellation_confirmation"),
message: cancellationConfirmationMessage,
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
if (!cancellationConfirmation) return;
const shouldInformParticipants = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/inform_participants"),
message: this.$p.t("coodle/email_participants_canceled"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
const cancellationResponse = await this.$api.call(
CoodleApi.cancelSurvey(
this.$props.survey.id,
),
);
if ((cancellationResponse.meta.status = "success")) {
this.$emit("surveyCanceled", { shouldInformParticipants });
}
},
async sendReminders() {
if (!this.$props.survey?.id) return;
const numberOfParticipantsWithoutVote =
this.$props.survey.participants.filter(
(participant) => participant.selection === null,
).length;
if (!numberOfParticipantsWithoutVote) {
this.$fhcAlert.alertError(
this.$p.t("coodle/all_participants_voted"),
);
return;
}
const reminderConfirmation = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/reminder_confirmation"),
message: this.$p.t("coodle/email_participants_reminder"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
if (!reminderConfirmation) return;
const remindersResponse = await this.$api.call(
CoodleApi.sendReminders(this.$props.survey.id),
);
if (remindersResponse.meta.status === "success") {
this.$fhcAlert.alertSuccess(
this.$p.t("coodle/reminders_success"),
);
}
},
},
template: /*html*/ `
<div class="d-flex flex-row align-items-center justify-content-between">
<h4 class="text-wrap m-0">{{ headerTitle }}</h4>
<div
v-if="isAuthUserSurveyCreator && !isEditInProgress && isSurveyActive"
class="dropdown"
>
<div
role="button"
class="px-2"
data-bs-toggle="dropdown"
data-bs-auto-close="true"
aria-expanded="false"
>
<i class="fa-solid fa-ellipsis-vertical fa-lg"></i>
</div>
<ul class="dropdown-menu dropdown-menu-end" style="min-width:0;'">
<li>
<div
@click="sendReminders()"
role="button"
class="dropdown-item px-3 py-1"
>
{{ $p.t("coodle/send_reminders") }}
</div>
</li>
<li>
<div
@click="$emit('editSurvey')"
role="button"
class="dropdown-item px-3 py-1"
>
{{ $p.t("coodle/edit_survey") }}
</div>
</li>
<li>
<div
@click="cancelSurvey()"
role="button"
class="dropdown-item px-3 py-1"
>
{{ $p.t("coodle/cancel_survey") }}
</div>
</li>
</ul>
</div>
</div>
`,
};
@@ -0,0 +1,448 @@
import CoodleApi from "../../../../../api/factory/coodle.js";
import { debounce } from "../../../../../helpers/DebounceHelper.js";
export default {
name: "CoodleSurveyParticipants",
components: {},
props: {
participantsModelValue: Array,
externalParticipantsModelValue: Array,
participantScheduleColorsModelValue: Array,
authInfo: { type: Object | null },
},
emits: [
"update:participantsModelValue",
"update:externalParticipantsModelValue",
"update:participantScheduleColorsModelValue",
],
inject: {
isMobile: "isMobile",
},
data() {
return {
searchInput: "",
isSearchingForParticipants: false,
searchResults: [],
areSearchResultsShown: false,
warningGroupSize: 10,
searchParticipantsAbortController: null,
searchParticipants: debounce(async () => {
if (this.searchInput.length < 2) return;
if (this.searchParticipantsAbortController)
this.searchParticipantsAbortController.abort();
this.searchParticipantsAbortController = new AbortController();
this.isSearchingForParticipants = true;
const participantsSearchResponse = await this.$api.call(
CoodleApi.searchParticipants(this.searchInput),
{ signal: this.searchParticipantsAbortController.signal },
);
this.isSearchingForParticipants = false;
this.parseParticipantSearchResults(
participantsSearchResponse.data,
);
}, 300),
hideSearchResults: debounce(async () => {
this.areSearchResultsShown = false;
}, 100),
externalParticipantNameInput: "",
externalParticipantEmailInput: "",
};
},
computed: {
participants: {
get() {
return this.$props.participantsModelValue;
},
set(newValue) {
this.$emit("update:participantsModelValue", newValue);
},
},
externalParticipants: {
get() {
return this.$props.externalParticipantsModelValue;
},
set(newValue) {
this.$emit("update:externalParticipantsModelValue", newValue);
},
},
participantScheduleColors: {
get() {
return this.$props.participantScheduleColorsModelValue;
},
set(newValue) {
this.$emit(
"update:participantScheduleColorsModelValue",
newValue,
);
},
},
isMaxDisplayedParticipantSchedulesReached() {
return (
this.displayedParticipantSchedulesCount >=
this.participantScheduleColors.length
);
},
displayedParticipantSchedulesCount() {
return this.participants.filter(
(participant) => participant.isCalendarShown,
).length;
},
hasAuthUserBeenAddedAsParticipant() {
return this.participants.some(
(participant) => participant.uid === this.$props.authInfo?.uid,
);
},
},
watch: {
searchInput() {
if (this.searchInput.length >= 2) {
this.areSearchResultsShown = true;
this.searchParticipants();
} else {
this.areSearchResultsShown = false;
}
},
},
methods: {
parseParticipantSearchResults(searchResults) {
this.searchResults = searchResults
.map((searchResult) => {
let data = JSON.parse(searchResult.data);
if (data.uid) {
return {
rank: searchResult.rank,
type: "user",
name: data.name,
uid: data.uid,
};
} else {
return {
rank: searchResult.rank,
type: "group",
name: data.name,
users: data.user_uids.map((userUid, index) => {
return {
uid: userUid,
name: data.user_names[index],
};
}),
};
}
})
.filter((searchResult) => {
return (
searchResult.type === "group" ||
!this.participants.some(
(existingParticipant) =>
existingParticipant.uid === searchResult.uid,
)
);
})
.toSorted(
(searchResultA, searchResultB) =>
searchResultB.rank - searchResultA.rank,
);
},
async selectSearchResult(searchResult) {
if (
searchResult.type === "group" &&
searchResult.users.length >= this.warningGroupSize
) {
let groupSizeWarningMessage = this.$p.t(
"coodle/group_size_warning",
);
groupSizeWarningMessage = groupSizeWarningMessage.replace(
"(((groupName)))",
searchResult.name,
);
groupSizeWarningMessage = groupSizeWarningMessage.replaceAll(
"(((n)))",
searchResult.users.length,
);
const groupSizeConfirmation = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/warning"),
message: groupSizeWarningMessage,
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!groupSizeConfirmation) return;
}
const addedParticipants =
searchResult.type === "group"
? searchResult.users
: [searchResult];
this.addParticipants(addedParticipants);
this.searchInput = "";
this.searchResults = [];
},
addParticipants(newParticipants) {
newParticipants = newParticipants
.filter((newParticipant) => {
return !this.participants.some(
(existingParticipant) =>
existingParticipant.uid === newParticipant.uid,
);
})
.map((newParticipant) => {
return {
uid: newParticipant.uid,
name: newParticipant.name,
isCalendarShown: false,
};
});
this.participants = this.participants.concat(newParticipants);
},
removeParticipant(participantToBeRemoved) {
this.participants = this.participants.filter(
(participant) => participant.uid !== participantToBeRemoved.uid,
);
let occupiedParticipantColor = this.participantScheduleColors.find(
(participantColor) =>
participantColor.uid === participantToBeRemoved.uid,
);
if (!occupiedParticipantColor) return;
occupiedParticipantColor.uid = null;
},
showSchedule(participantForCalendarToBeDisplayed) {
if (this.isMaxDisplayedParticipantSchedulesReached) return;
this.participants = this.participants.map((participant) => {
let updatedParticipant = { ...participant };
if (
participant.uid === participantForCalendarToBeDisplayed.uid
) {
updatedParticipant.isCalendarShown = true;
}
return updatedParticipant;
});
this.participantScheduleColors.find(
(participantColor) => !participantColor.uid,
).uid = participantForCalendarToBeDisplayed.uid;
},
hideSchedule(participant) {
participant.isCalendarShown = false;
let occupiedParticipantColor = this.participantScheduleColors.find(
(participantColor) => participantColor.uid === participant.uid,
);
if (!occupiedParticipantColor) return;
occupiedParticipantColor.uid = null;
},
hideAllSchedules() {
this.participants.forEach((participant) => {
participant.isCalendarShown = false;
});
this.participantScheduleColors.forEach((participantColor) => {
participantColor.uid = null;
});
},
getParticipantScheduleColor(participant) {
return this.participantScheduleColors.find(
(participantColor) => participantColor.uid === participant.uid,
)?.color;
},
addExternalParticipant() {
const nameInput = this.externalParticipantNameInput.trim();
const emailInput = this.externalParticipantEmailInput.trim();
if (!nameInput.length || !emailInput.length) {
this.$fhcAlert.alertError("Missing inputs!");
return;
}
const emailRegex =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(emailInput)) {
this.$fhcAlert.alertError("Invalid email format!");
return;
}
if (
this.externalParticipants.some(
(externalParticipant) =>
externalParticipant.email === emailInput,
)
) {
this.$fhcAlert.alertError(
this.$p.t("coodle/duplicates_not_allowed"),
);
return;
}
this.externalParticipants = [
...this.externalParticipants,
{
name: nameInput,
email: emailInput,
},
];
this.externalParticipantNameInput = "";
this.externalParticipantEmailInput = "";
},
removeExternalParticipant(email) {
this.externalParticipants = this.externalParticipants.filter(
(externalParticipant) => externalParticipant.email !== email,
);
},
},
template: /*html*/ `
<div class="row mb-3">
<div class="col-12 col-md-6 col-xxl-12 d-flex flex-column gap-1 mb-2">
<label for="searchInput" class="fw-bold">{{ $p.t("coodle/int_participants") }}</label>
<div class="position-relative mb-1">
<input
v-model="searchInput"
@focus="areSearchResultsShown = searchInput.length >= 2"
@blur="hideSearchResults()"
:placeholder="'Search participants...'"
id="searchInput"
class="w-100"
/>
<div
v-if="areSearchResultsShown"
id="coodleParticipantsSearchResults"
class="position-absolute w-100 d-flex flex-column overflow-y-auto"
style="height:200px;"
>
<div
v-if="isSearchingForParticipants"
class="flex-grow-1 d-flex flex-row justify-content-center align-items-center"
>
<i class="fa-solid fa-spinner fa-spin fa-xl"></i>
</div>
<div
v-else-if="!searchResults.length"
class="d-flex flex-row justify-content-center align-items-center py-3"
>
<span>{{ $p.t("coodle/no_results_found") }}</span>
</div>
<div v-else>
<div
v-for="searchResult in searchResults"
@click="selectSearchResult(searchResult)"
type="button"
class="coodleParticipantSearchResult d-flex flex-row gap-2 justify-content-start align-items-center py-1 px-2 border-bottom border-1"
>
<i v-if="searchResult.type === 'user'" class="fa-solid fa-user"></i>
<i v-else-if="searchResult.type === 'group'" class="fa-solid fa-user-group"></i>
<span>{{ searchResult.name + (searchResult.uid ? (" (" + searchResult.uid + ")") : "") }}</span>
</div>
</div>
</div>
</div>
<div v-if="participants.length" class="d-flex flex-row gap-2 flex-wrap">
<div
v-for="participant in participants"
class="d-flex flex-row align-items-center gap-4 py-1 px-3 border border-1 rounded-pill"
>
<span>{{ participant.name }}</span>
<div class="d-flex flex-row align-items-center gap-3">
<i
v-if="participant.isCalendarShown"
@click="hideSchedule(participant)"
type="button"
class="fa-solid fa-calendar"
:style="{color: getParticipantScheduleColor(participant)}"
></i>
<i
v-else-if="isMaxDisplayedParticipantSchedulesReached"
class="fa-regular fa-calendar-xmark"
></i>
<i
v-else
@click="showSchedule(participant)"
type="button"
class="fa-regular fa-calendar"
></i>
<i @click="removeParticipant(participant)" type="button" class="fa-solid fa-xmark"></i>
</div>
</div>
</div>
<div v-if="!hasAuthUserBeenAddedAsParticipant && $props.authInfo" class="d-flex flex-row">
<div
@click="addParticipants([$props.authInfo])"
type="button"
class="py-1 px-3 border border-1 rounded-pill"
>
<span>{{ $p.t("coodle/add_yourself") }}</span>
</div>
</div>
<span v-if="isMaxDisplayedParticipantSchedulesReached" class="fst-italic">
{{ $p.t("coodle/max_participant_schedules") }}
</span>
<div>
<span
v-if="displayedParticipantSchedulesCount"
@click="hideAllSchedules()"
type="button"
class="text-decoration-underline"
>
{{ $p.t("coodle/hide_participant_schedules") }}
</span>
</div>
</div>
<div class="col-12 col-md-6 col-xxl-12 d-flex flex-column gap-1 mb-2">
<span class="fw-bold">{{ $p.t("coodle/ext_participants") }}</span>
<div class="d-flex flex-row gap-2 mb-1">
<div class="flex-grow-1 d-flex flex-column gap-2">
<div >
<input
v-model="externalParticipantNameInput"
:placeholder="'Name'"
id="externalParticipantNameInput"
maxlength="255"
style="width:100%"
/>
</div>
<div >
<input
v-model="externalParticipantEmailInput"
:placeholder="'Email'"
type="email"
id="externalParticipantEmailInput"
maxlength="255"
style="width:100%"
/>
</div>
</div>
<div class="d-flex flex-row align-items-center justify-content-center">
<div @click="addExternalParticipant()" type="button" class="p-1 fhcPrimaryHover">
<i class="fa-solid fa-circle-plus fa-xl"></i>
</div>
</div>
</div>
<div v-if="externalParticipants.length" class="d-flex flex-row gap-2 flex-wrap">
<div
v-for="externalParticipant in externalParticipants"
:key="externalParticipant.email"
class="d-flex flex-row align-items-center gap-4 py-1 px-3 border border-1 rounded-pill"
>
<div class="d-flex flex-row gap-2 flex-wrap">
<span>{{ externalParticipant.name }}</span>
<span>|</span>
<span>{{ externalParticipant.email }}</span>
</div>
<i
@click="removeExternalParticipant(externalParticipant.email)"
type="button"
class="fa-solid fa-xmark"
></i>
</div>
</div>
</div>
<span v-if="!participants.length && !externalParticipants.length" class="fst-italic">
{{ $p.t("coodle/no_participants_yet") }}
</span>
</div>
`,
};
@@ -0,0 +1,782 @@
import CoodleApi from "../../../../../api/factory/coodle.js";
import OrtApi from "../../../../../api/factory/ort.js";
export default {
name: "CoodleSurveyVotingTable",
props: {
survey: { type: Object },
authUid: { type: String | null },
authExternalParticipantId: { type: Number | null },
timeslots: { type: Array },
},
emits: ["selectionSubmitted", "surveyCompleted"],
data() {
return {
isVotingInProgress: false,
isFinalSelectionInProgress: false,
participantsWithoutAuthUser: [],
externalParticipantsWithoutAuthUser: [],
authUserParticipant: null,
editableAuthUserSelection: null,
selectedTimeslotId: null,
isRoomSelectionShown: false,
selectedRoomIdentifier: null,
isFetchingAvailableRooms: false,
availableRooms: [],
roomFilterText: "",
};
},
computed: {
isDarkMode() {
return this.$theme.theme_name.value == "dark";
},
isAuthUserSurveyCreator() {
return (
this.$props.authUid &&
this.$props.authUid === this.$props.survey?.creator?.uid
);
},
hasReachedMaxSelections() {
if (!this.authUserParticipant || !this.editableAuthUserSelection)
return false;
return (
Object.values(this.editableAuthUserSelection).filter(
(isSelected) => isSelected,
).length >= this.$props.survey.maxSelections
);
},
isSurveyActive() {
return (
!this.$props.survey.completedAt &&
!this.$props.survey.canceledAt
);
},
filteredAvailableRooms() {
if (!this.roomFilterText?.length) {
return this.availableRooms;
}
const roomFilterText = this.roomFilterText.toLowerCase();
return this.availableRooms.filter(
(room) =>
room.shortName.toLowerCase().includes(roomFilterText) ||
room.longName.toLowerCase().includes(roomFilterText),
);
},
timeslotDates() {
if (!this.$props.timeslots.length) return [];
let groupedTimeslots = Object.groupBy(
this.$props.timeslots,
(timeslot) => {
return timeslot.startsAt.toISOString().slice(0, 10);
},
);
return Object.keys(groupedTimeslots)
.sort()
.map((dateKey) => {
let timeslotDate = groupedTimeslots[dateKey];
return {
count: timeslotDate.length,
date: timeslotDate[0].date,
weekday: timeslotDate[0].weekday,
month: timeslotDate[0].month,
};
});
},
},
watch: {
survey: {
handler() {
this.setData();
},
deep: true,
},
timeslots: {
handler() {
this.setData();
},
deep: true,
},
authUid() {
this.setData();
},
authExternalParticipantId() {
this.setData();
},
authUserParticipant: {
handler() {
this.setEditableAuthUserSelection();
},
deep: true,
},
editableAuthUserSelection: {
handler() {
if (this.editableAuthUserSelection.none) {
Object.keys(this.editableAuthUserSelection).forEach(
(timeslotId) => {
if (timeslotId !== "none") {
this.editableAuthUserSelection[timeslotId] =
false;
}
},
);
}
},
deep: true,
},
isVotingInProgress() {
this.setEditableAuthUserSelection();
},
isFinalSelectionInProgress() {
this.setSelectedTimeslotId();
},
},
methods: {
parseParticipants() {
const participants = this.$props.survey.participants.map(
(participant) => {
participant = { ...participant };
const hasVotedWithoutSelection =
participant.selection &&
participant.selection.length === 0;
let selectionEntries = this.$props.timeslots.map(
(timeslot) => [
timeslot.id,
participant.selection?.includes(timeslot.id),
],
);
selectionEntries.push(["none", hasVotedWithoutSelection]);
participant.selection =
Object.fromEntries(selectionEntries);
return participant;
},
);
if (this.$props.authUid) {
this.authUserParticipant =
participants.find(
(participant) =>
participant.uid === this.$props.authUid,
) ?? null;
}
this.participantsWithoutAuthUser = this.$props.authUid
? participants.filter(
(participant) =>
participant.uid !== this.$props.authUid,
)
: participants;
},
parseExternalParticipants() {
const externalParticipants =
this.$props.survey.externalParticipants.map(
(externalParticipant) => {
externalParticipant = { ...externalParticipant };
const hasVotedWithoutSelection =
externalParticipant.selection &&
externalParticipant.selection.length === 0;
let selectionEntries = this.$props.timeslots.map(
(timeslot) => [
timeslot.id,
externalParticipant.selection?.includes(
timeslot.id,
),
],
);
selectionEntries.push([
"none",
hasVotedWithoutSelection,
]);
externalParticipant.selection =
Object.fromEntries(selectionEntries);
return externalParticipant;
},
);
if (this.$props.authExternalParticipantId) {
this.authUserParticipant = externalParticipants.find(
(externalParticipant) =>
externalParticipant.id ===
this.$props.authExternalParticipantId,
);
}
this.externalParticipantsWithoutAuthUser = this.$props
.authExternalParticipantId
? externalParticipants.filter(
(externalParticipant) =>
externalParticipant.id !==
this.$props.authExternalParticipantId,
)
: externalParticipants;
},
setData() {
this.setForms();
this.setSelectedTimeslotId();
this.parseParticipants();
this.parseExternalParticipants();
},
setForms() {
this.isVotingInProgress = false;
this.isFinalSelectionInProgress = false;
this.isRoomSelectionShown = false;
this.selectedRoomIdentifier = null;
this.roomFilterText = "";
},
setEditableAuthUserSelection() {
this.editableAuthUserSelection = this.authUserParticipant?.selection
? { ...this.authUserParticipant.selection }
: null;
},
setSelectedTimeslotId() {
if (!this.$props.survey.completedAt) {
this.selectedTimeslotId = null;
} else if (this.$props.survey.selectedTimeslotId) {
this.selectedTimeslotId = this.$props.survey.selectedTimeslotId;
} else {
this.selectedTimeslotId = "none";
}
},
async submitParticipantSelection() {
const selectedTimeslots = Object.entries(
this.editableAuthUserSelection,
)
.filter((timeslotSelectionPair) => timeslotSelectionPair[1])
.map((timeslotSelectionPair) => timeslotSelectionPair[0]);
let selection;
if (!selectedTimeslots.length) {
selection = null;
} else if (selectedTimeslots.includes("none")) {
selection = [];
} else {
selection = selectedTimeslots.map(Number);
}
let selectionSubmissionResponse;
if (this.$props.authExternalParticipantId) {
selectionSubmissionResponse = await this.$api.call(
CoodleApi.submitExternalParticipantSelection(
this.$route.params.surveyId,
this.$route.params.accessKey,
selection,
),
);
} else {
selectionSubmissionResponse = await this.$api.call(
CoodleApi.submitParticipantSelection(
this.survey.id,
selection,
),
);
}
if (selectionSubmissionResponse.meta.status === "success") {
this.$emit("selectionSubmitted");
}
},
async prepareFinalSelectionSubmission() {
if (!this.$props.survey.id) return;
if (!this.selectedTimeslotId) {
this.$fhcAlert.alertError(
this.$p.t("coodle/no_selection_made"),
);
return;
}
let formattedSelectedTimeslot = "";
let selectedTimeslot = null;
if (this.selectedTimeslotId === "none") {
formattedSelectedTimeslot =
'"' + this.$p.t("coodle/no_timeslot_possible") + '"';
} else {
selectedTimeslot = this.$props.timeslots.find(
(timeslot) => timeslot.id === this.selectedTimeslotId,
);
if (!selectedTimeslot) return;
formattedSelectedTimeslot =
selectedTimeslot.weekday +
" " +
selectedTimeslot.fullDate +
" " +
selectedTimeslot.startTime +
"-" +
selectedTimeslot.endTime;
}
const finalSelectionConfirmationMessage = this.$p
.t("coodle/survey_completion_confirmation")
.replace("(((timeslot)))", formattedSelectedTimeslot)
.replace("(((surveyTitle)))", this.$props.survey?.title);
const finalSelectionConfirmation = await this.$fhcAlert.confirm({
header: "Final confirmation",
message: finalSelectionConfirmationMessage,
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!finalSelectionConfirmation) return;
let participantsThatHaveNotVoted =
this.participantsWithoutAuthUser.reduce((sum, participant) => {
return (
sum +
(!Object.values(participant.selection).filter(
(isSelected) => isSelected,
).length
? 1
: 0)
);
}, 0);
if (
this.authUserParticipant &&
!Object.values(this.authUserParticipant.selection).filter(
(isSelected) => isSelected,
).length
) {
participantsThatHaveNotVoted++;
}
if (participantsThatHaveNotVoted) {
const shouldProceedWithoutAllVotes =
await this.$fhcAlert.confirm({
header: this.$p.t("coodle/warning"),
message: this.$p.t("coodle/continue_without_all_votes"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("ui/abbrechen"),
});
if (!shouldProceedWithoutAllVotes) return;
}
const now = new Date();
const isFutureTimeslot =
selectedTimeslot && selectedTimeslot.startsAt > now;
if (selectedTimeslot && isFutureTimeslot) {
const shouldReserveARoom = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/room_reservation"),
message: this.$p.t("coodle/reserve_room_confirmation"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
if (shouldReserveARoom) {
this.isRoomSelectionShown = true;
this.fetchAvailableRooms();
return;
}
}
this.submitFinalSelection();
},
submitWithRoomSelection() {
if (this.availableRooms.length && !this.selectedRoomIdentifier) {
this.$fhcAlert.alertError(
this.$p.t("coodle/no_room_selection"),
);
return;
}
this.submitFinalSelection();
},
async submitFinalSelection() {
let shouldInformParticipants = false;
const selectedTimeslotId =
this.selectedTimeslotId === "none"
? null
: this.selectedTimeslotId;
if (!selectedTimeslotId) {
shouldInformParticipants = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/inform_participants"),
message: this.$p.t("coodle/email_participants_result"),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
} else {
const selectedTimeslot = this.$props.timeslots.find(
(timeslot) => timeslot.id === selectedTimeslotId,
);
const now = new Date();
const isFutureTimeslot = selectedTimeslot.startsAt > now;
if (isFutureTimeslot) {
shouldInformParticipants = await this.$fhcAlert.confirm({
header: this.$p.t("coodle/inform_participants"),
message: this.$p.t(
"coodle/email_participants_calendar_invite",
),
acceptLabel: this.$p.t("coodle/yes"),
rejectLabel: this.$p.t("coodle/no"),
});
}
}
const completionResponse = await this.$api.call(
CoodleApi.completeSurvey(
this.$props.survey.id,
selectedTimeslotId,
this.selectedRoomIdentifier,
),
);
if (completionResponse.meta.status === "success") {
this.$emit("surveyCompleted", {
shouldInformParticipants,
selectedRoomId: this.selectedRoomIdentifier,
});
}
},
async fetchAvailableRooms() {
this.availableRooms = [];
const selectedTimeslotInfo = this.$props.timeslots.find(
(timeslot) => timeslot.id === this.selectedTimeslotId,
);
this.isFetchingAvailableRooms = true;
const roomsResponse = await this.$api.call(
OrtApi.getRooms(
selectedTimeslotInfo.startsAt.toISOString(),
selectedTimeslotInfo.startTime,
selectedTimeslotInfo.endTime,
"",
this.$props.survey.participants.length,
),
);
this.isFetchingAvailableRooms = false;
this.availableRooms = roomsResponse.data.retval
.filter((room) => room.reservieren)
.map((room) => {
return {
id: room.content_id,
shortName: room.ort_kurzbz,
longName: room.bezeichnung,
};
});
},
cancelRoomSelection() {
this.isRoomSelectionShown = false;
this.selectedRoomIdentifier = null;
this.availableRooms = [];
this.isFetchingAvailableRooms = false;
this.roomFilterText = "";
},
showParticipantProfile(participant) {
if (!participant?.uid) {
return;
}
const participantHref = this.$router.resolve({
name: "ProfilView",
params: { uid: participant.uid },
}).href;
window.open(participantHref, "_blank");
},
showRoomDetails(room) {
if (!room.id) {
return;
}
const roomHref =
FHC_JS_DATA_STORAGE_OBJECT.app_root +
FHC_JS_DATA_STORAGE_OBJECT.ci_router +
"/CisVue/Cms/content/" +
room.id;
window.open(roomHref, "_blank");
},
},
created() {
this.setData();
this.setEditableAuthUserSelection();
},
template: /*html*/ `
<div>
<span v-if="!isSurveyActive" class="fst-italic">
{{ $p.t("coodle/voting_closed") }}
</span>
<span v-else-if="authUserParticipant" :class="{'opacity-0': !isVotingInProgress}" class="fst-italic">
{{
$props.survey?.maxSelections === 1 ?
$p.t("coodle/max_one_selectable_timeslot") :
$p.t("coodle/up_to_max_selectable_timeslots").replace("(((n)))", $props.survey?.maxSelections)
}}
</span>
<div class="d-flex flex-row">
<div class="d-flex flex-column flex-shrink-1" style="max-width:100%;">
<div class="overflow-x-auto">
<table>
<tr>
<td rowspan="2" class="border-1"></td>
<td v-for="timeslotDate in timeslotDates" :colspan="timeslotDate.count" class="border-1">
<div class="px-1 d-flex flex-column align-items-center">
<span>{{ timeslotDate.month.slice(0,3) }}</span>
<span class="fs-5 fw-bold">{{ timeslotDate.date }}</span>
<span>{{ timeslotDate.weekday.slice(0,3) }}</span>
</div>
</td>
<td rowspan="2" class="border-1">
<div class="px-2 py-1 text-center">
{{ $p.t("coodle/no_timeslot_possible") }}
</div>
</td>
</tr>
<tr>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="px-2 d-flex flex-column align-items-start text-nowrap">
<span>{{ timeslot.startTime + " -" }}</span>
<span>{{ timeslot.endTime }}</span>
</div>
</td>
</tr>
<tr v-for="participant in participantsWithoutAuthUser">
<td class="border-1 px-2 py-1">
<div
v-if="participant.name"
class="d-flex flex-row gap-1 justify-content-between align-items-center"
>
<span>
{{ participant.name }}
</span>
<span
v-if="participant.uid"
@click="showParticipantProfile(participant)"
type="button"
class="px-1 fhc-primary-color"
>
<i class="fa-solid fa-up-right-from-square"></i>
</span>
</div>
<div v-else class="text-center">---</div>
</td>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="d-flex justify-content-center">
<i v-if="participant.selection[timeslot.id]" class="fa-solid fa-check"></i>
</div>
</td>
<td class="border-1">
<div class="d-flex justify-content-center">
<i v-if="participant.selection.none" class="fa-solid fa-check"></i>
</div>
</td>
</tr>
<tr v-for="externalParticipant in externalParticipantsWithoutAuthUser">
<td class="border-1 px-2 py-1">
<div
v-if="externalParticipant.name?.length"
class="d-flex flex-row gap-1 justify-content-between align-items-center"
>
{{ externalParticipant.name }}
</div>
<div v-else class="text-center">---</div>
</td>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="d-flex justify-content-center">
<i v-if="externalParticipant.selection[timeslot.id]" class="fa-solid fa-check"></i>
</div>
</td>
<td class="border-1">
<div class="d-flex justify-content-center">
<i v-if="externalParticipant.selection.none" class="fa-solid fa-check"></i>
</div>
</td>
</tr>
<tr
v-if="authUserParticipant"
:class="{'fhc-body-bg': isVotingInProgress}"
>
<td class="border-1 px-2 py-1">{{ authUserParticipant.name }}</td>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="d-flex justify-content-center py-3">
<input
v-if="isSurveyActive"
v-model="editableAuthUserSelection[timeslot.id]"
:disabled="
!isVotingInProgress ||
(!editableAuthUserSelection[timeslot.id] && hasReachedMaxSelections) ||
editableAuthUserSelection.none
"
:role="!isVotingInProgress ||
(!editableAuthUserSelection[timeslot.id] && hasReachedMaxSelections) ||
editableAuthUserSelection.none ? '' : 'button'"
type="checkbox"
style="width: 20px; height: 20px;"
/>
<i v-else-if="authUserParticipant.selection[timeslot.id]" class="fa-solid fa-check"></i>
</div>
</td>
<td class="border-1">
<div class="d-flex justify-content-center py-3">
<input
v-if="isSurveyActive"
v-model="editableAuthUserSelection.none"
:disabled="!isVotingInProgress"
:role="isVotingInProgress ? 'button' : ''"
type="checkbox"
style="width: 20px; height: 20px;"
/>
<i v-else-if="authUserParticipant.selection.none" class="fa-solid fa-check"></i>
</div>
</td>
</tr>
<tr v-if="$props.survey.voteTallies" class="fw-bold">
<td class="border-1 px-2 py-1">{{ $p.t("coodle/vote_tally") }}</td>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="d-flex flex-row justify-content-center">
{{ $props.survey.voteTallies[timeslot.id] }}
</div>
</td>
<td class="border-1">
<div class="d-flex flex-row justify-content-center">
{{ $props.survey.voteTallies.none }}
</div>
</td>
</tr>
<tr
v-if="isAuthUserSurveyCreator || $props.survey.completedAt"
:class="{'fhc-body-bg': isFinalSelectionInProgress}"
class="fw-bold"
>
<td class="border-1 px-2 py-1">{{ $p.t("coodle/final_selection") }}</td>
<td v-for="timeslot in $props.timeslots" class="border-1">
<div class="d-flex flex-row justify-content-center py-3">
<input
v-if="isSurveyActive"
v-model="selectedTimeslotId"
:value="timeslot.id"
:disabled="!isFinalSelectionInProgress || isRoomSelectionShown"
:role="isFinalSelectionInProgress ? 'button' : ''"
type="radio"
style="width: 20px; height: 20px;"
/>
<i v-else-if="selectedTimeslotId === timeslot.id" class="fa-solid fa-check"></i>
</div>
</td>
<td class="border-1">
<div class="d-flex flex-row justify-content-center py-3">
<input
v-if="isSurveyActive"
v-model="selectedTimeslotId"
:value="'none'"
:disabled="!isFinalSelectionInProgress || isRoomSelectionShown"
:role="isFinalSelectionInProgress ? 'button' : ''"
type="radio"
style="width: 20px; height: 20px;"
/>
<i v-else-if="selectedTimeslotId === 'none'" class="fa-solid fa-check"></i>
</div>
</td>
</tr>
</table>
</div>
<div v-if="isRoomSelectionShown" class="mt-3">
<div v-if="isFetchingAvailableRooms" class="d-flex justify-content-center align-items-center py-3">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ $p.t("coodle/loading") }}</span>
</div>
</div>
<div v-else-if="!availableRooms.length" class="d-flex justify-content-center align-items-center py-4 px-1">
<h5 class="flex-shrink fw-bold text-wrap m-0">
{{ $p.t("coodle/no_rooms_available") }}
</h5>
</div>
<div v-else class="d-flex flex-column gap-2">
<div class="d-flex flex-row gap-1">
<span class="fw-bold">{{ "Selected room: " }}</span>
<span v-if="selectedRoomIdentifier?.length">{{ selectedRoomIdentifier }}</span>
</div>
<div class="d-flex flex-column gap-1 justify-content-start">
<span class="fw-bold">{{ $p.t("coodle/available_rooms") + ":" }}</span>
<div class="d-flex flex-row align-items-center gap-2">
<input v-model="roomFilterText" :placeholder="'Filter rooms...'" id="roomFilterInput" />
<div
v-if="roomFilterText?.length"
@click="roomFilterText = ''"
type="button"
class="px-1"
>
<i class="fa-solid fa-xmark fa-lg"></i>
</div>
</div>
</div>
<div v-if="!filteredAvailableRooms.length" class="d-flex flex-row align-items-center justify-content-center py-4 px-1">
<h5 class="flex-shrink fw-bold text-wrap m-0">
{{ $p.t("coodle/no_available_rooms_for_filter") }}
</h5>
</div>
<div v-else class="d-flex flex-column overflow-y-auto" style="max-height:200px;">
<div
v-for="room in filteredAvailableRooms"
@click="selectedRoomIdentifier = room.shortName"
:key="room.shortName"
:title="room.longName"
type="button"
:class="{'fhc-primary-color': selectedRoomIdentifier === room.shortName}"
class="px-2 py-1 border border-x-2 border-y-1 d-flex flex-row align-items-center gap-1"
>
<span>{{ room.shortName }}</span>
<span
v-if="room.id"
@click.stop="showRoomDetails(room)"
type="button"
class="px-1 fhc-primary-color"
>
<i class="fa-solid fa-up-right-from-square"></i>
</span>
</div>
</div>
</div>
</div>
<div v-if="isSurveyActive" class="d-flex flex-row gap-2 mt-3 justify-content-end">
<div
v-if="authUserParticipant && !isVotingInProgress && !isFinalSelectionInProgress"
@click="isVotingInProgress = true"
:class="isDarkMode ? 'btn-outline-light' : 'btn-outline-dark'"
class="btn text-nowrap"
>
{{ $p.t("coodle/vote") }}
</div>
<div
v-if="isAuthUserSurveyCreator && !isVotingInProgress && !isFinalSelectionInProgress"
@click="isFinalSelectionInProgress = true"
:class="isDarkMode ? 'btn-outline-light' : 'btn-outline-dark'"
class="btn text-nowrap"
>
{{ $p.t("coodle/finalize_survey") }}
</div>
<div
v-if="isVotingInProgress || isFinalSelectionInProgress"
@click="setForms()"
:class="isDarkMode ? 'btn-outline-light' : 'btn-outline-dark'"
class="btn text-nowrap"
>
{{ $p.t("ui/abbrechen") }}
</div>
<div
v-if="isVotingInProgress"
@click="submitParticipantSelection()"
:class="isDarkMode ? 'btn-light' : 'btn-dark'"
class="btn text-nowrap"
>
{{ $p.t("coodle/submit_vote") }}
</div>
<div
v-if="isFinalSelectionInProgress"
@click="isRoomSelectionShown ? submitWithRoomSelection() : prepareFinalSelectionSubmission()"
:class="isDarkMode ? 'btn-light' : 'btn-dark'"
class="btn text-nowrap"
>
{{ $p.t("coodle/submit_final_selection") }}
</div>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,157 @@
import { CoreFilterCmpt } from "../../../filter/Filter.js";
import { dateFilter } from "../../../../tabulator/filters/Dates.js";
import CoodleApi from "../../../../api/factory/coodle.js";
export default {
name: "InactiveCoodleSurveys",
components: { CoreFilterCmpt },
emits: ["showSurveyDetails"],
data() {
return {
inactiveSurveys: [],
inactiveSurveysTableConfig: {
persistenceID: "inactiveSurveysTable",
movableColumns: false,
minHeight: 300,
layout: "fitColumns",
initialSort: [{ column: "endedAt", dir: "desc" }],
pagination: true,
paginationSize: 25,
paginationCounter: "rows",
locale: true,
columns: [
{
title: "placeholder",
titlePhrase: "coodle/title",
field: "title",
headerFilter: true,
minWidth: 150,
},
{
title: "placeholder",
titlePhrase: "coodle/creator",
field: "creatorName",
headerFilter: true,
minWidth: 100,
},
{
title: "placeholder",
titlePhrase: "coodle/started_on",
field: "createdAt",
headerFilter: true,
minWidth: 100,
formatter: "datetime",
formatterParams: this.datetimeFormatterParams(),
headerFilterFunc: "dates",
headerFilter: dateFilter,
},
{
title: "placeholder",
titlePhrase: "coodle/ended_on",
field: "endedAt",
headerFilter: true,
minWidth: 100,
formatter: "datetime",
formatterParams: this.datetimeFormatterParams(),
headerFilterFunc: "dates",
headerFilter: dateFilter,
},
],
},
isFetchingSurveys: false,
};
},
methods: {
async fetchInactiveSurveys() {
this.isFetchingSurveys = true;
const surveysResponse = await this.$api.call(
CoodleApi.getInactiveSurveys(),
);
this.isFetchingSurveys = false;
if (surveysResponse.meta.status !== "success") return;
this.inactiveSurveys = surveysResponse.data.map((surveyData) => {
return {
id: surveyData.id,
title: surveyData.title,
createdAt: surveyData.created_at.split(".")[0],
completedAt: surveyData.completed_at?.split(".")[0],
canceledAt: surveyData.canceled_at?.split(".")[0],
creator: {
uid: surveyData.creator.uid,
name: surveyData.creator.name,
},
};
});
this.inactiveSurveysTableConfig.data = this.inactiveSurveys.map(
(survey) => {
return {
id: survey.id,
title: survey.title,
creatorName: survey.creator.name,
createdAt: survey.createdAt,
endedAt: survey.completedAt ?? survey.canceledAt,
};
},
);
},
afterInactiveSurveysTableBuilt() {
this.$refs.inactiveSurveysTable.tabulator.on(
"rowClick",
(event, row) => {
this.$emit("showSurveyDetails", {
surveyId: row._row.data.id,
});
},
);
},
datetimeFormatterParams: function () {
const params = {
inputFormat: "yyyy-MM-dd hh:mm:ss",
outputFormat: "dd.MM.yyyy",
invalidPlaceholder: "(invalid date)",
timezone: FHC_JS_DATA_STORAGE_OBJECT.timezone,
};
return params;
},
},
async created() {
await this.fetchInactiveSurveys();
},
template: /*html*/ `
<div class="card mb-4" style="height:100%">
<div class="card-body">
<div
v-if="isFetchingSurveys"
class="d-flex justify-content-center align-items-center mt-5"
>
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ $p.t("coodle/loading") }}</span>
</div>
</div>
<div
v-else-if="!inactiveSurveys?.length"
class="d-flex justify-content-center align-items-center mt-5"
>
<h3 class="fw-bold">{{$p.t("coodle/no_inactive_surveys_found") }}</h3>
</div>
<div v-else class="d-flex flex-row overflow-x-auto">
<div class="flex-shrink">
<core-filter-cmpt
@tableBuilt="afterInactiveSurveysTableBuilt()"
:noColumnFilter="true"
:title="'Past surveys'"
:ref="'inactiveSurveysTable'"
:tabulatorOptions="inactiveSurveysTableConfig"
:sideMenu="false"
tableOnly
/>
</div>
</div>
</div>
</div>
`,
};
+226
View File
@@ -0,0 +1,226 @@
import ActiveCoodleSurveys from "./Components/ActiveCoodleSurveys.js";
import InactiveCoodleSurveys from "./Components/InactiveCoodleSurveys.js";
import CoodleSurvey from "./Components/CoodleSurvey.js";
import CoodleFreeBusy from "./Components/CoodleFreeBusy.js";
import CoodleIcal from "./Components/CoodleIcal.js";
import ApiAuthinfo from "../../../api/factory/authinfo.js";
import CoodleApi from "../../../api/factory/coodle.js";
export default {
name: "Coodle",
components: {
ActiveCoodleSurveys,
InactiveCoodleSurveys,
CoodleSurvey,
CoodleFreeBusy,
CoodleIcal,
},
data() {
return {
authInfo: null,
view: "activeSurveysTable",
survey: null,
};
},
computed: {
isDarkMode() {
return this.$theme.theme_name.value == "dark";
},
},
methods: {
getTabStylingClass(isSelected) {
if (this.isDarkMode && isSelected) {
return "btn-light";
} else if (this.isDarkMode && !isSelected) {
return "btn-outline-light";
} else if (!this.isDarkMode && isSelected) {
return "btn-dark";
} else {
return "btn-outline-dark";
}
},
switchToTab(tab) {
if (this.view === tab && tab !== "survey") return;
this.view = tab;
this.survey = null;
},
async getAuthInfo() {
const authInfoResponse = await this.$api.call(
ApiAuthinfo.getAuthInfo(),
);
this.authInfo = {
uid: authInfoResponse.data.uid,
name: authInfoResponse.data.name,
};
},
async showSurveyDetails(surveyId) {
const surveyResponse = await this.$api.call(
CoodleApi.getSurvey(surveyId),
);
if (surveyResponse.meta.status === "success") {
this.survey = this.parseIncomingSurveyData(surveyResponse.data);
this.view = "survey";
} else {
this.view = "activeSurveysTable";
}
},
parseIncomingSurveyData(surveyData) {
return {
id: surveyData.id,
creator: surveyData.creator,
title: surveyData.title,
description: surveyData.description,
timeslotDuration: surveyData.timeslot_duration,
maxSelections: surveyData.max_selections,
areParticipantsAnonymized:
surveyData.are_participants_anonymized,
areSelectionsAnonymized: surveyData.are_selections_anonymized,
selectedTimeslotId: surveyData.selected_timeslot_id,
endsAt: surveyData.ends_at.split(" ")[0],
completedAt: surveyData.completed_at,
canceledAt: surveyData.canceled_at,
createdAt: surveyData.created_at.split(" ")[0],
updatedAt: surveyData.updated_at.split(" ")[0],
timeslots: surveyData.timeslots.map((timeslot) => {
return {
id: timeslot.id,
startsAt: timeslot.starts_at,
};
}),
participants: surveyData.participants,
externalParticipants: surveyData.external_participants,
voteTallies: surveyData.vote_tallies,
};
},
async afterSurveyCreated(surveyId, shouldInformParticipants) {
await this.showSurveyDetails(surveyId);
if (shouldInformParticipants) {
this.$api.call(
CoodleApi.emailParticipants(surveyId, "created"),
);
}
},
async afterSurveyUpdated(surveyId, shouldInformParticipants) {
await this.showSurveyDetails(surveyId);
if (shouldInformParticipants) {
this.$api.call(
CoodleApi.emailParticipants(surveyId, "updated"),
);
}
},
async afterSurveyCanceled(surveyId, shouldInformParticipants) {
await this.showSurveyDetails(surveyId);
if (shouldInformParticipants) {
this.$api.call(
CoodleApi.emailParticipants(surveyId, "canceled"),
);
}
},
async afterSurveyCompleted(
surveyId,
shouldInformParticipants,
selectedRoomId,
) {
await this.showSurveyDetails(surveyId);
if (shouldInformParticipants) {
this.$api.call(
CoodleApi.emailParticipants(surveyId, "completed", {
selectedRoomId,
}),
);
}
},
},
async created() {
await this.getAuthInfo();
await this.$p.loadCategory(["coodle", "global", "ui"]);
if (this.$route.query?.id?.length) {
this.showSurveyDetails(this.$route.query.id);
this.$router.replace({ query: null });
}
},
template: /*html*/ `
<div class="h-100 d-flex flex-column gap-2">
<h2 class="m-0">Coodle</h2>
<div>
<div class="d-flex flex-row gap-2 w-100 overflow-x-auto">
<div
@click="switchToTab('activeSurveysTable')"
:class="getTabStylingClass(view === 'activeSurveysTable')"
class="btn d-flex flex-row gap-2 align-items-center py-2"
>
<div>
<i class="fa-solid fa-person-booth fa-lg"></i>
</div>
<span class="text-nowrap">{{ $p.t("coodle/active_surveys") }}</span>
</div>
<div
@click="switchToTab('pastSurveysTable')"
:class="getTabStylingClass(view === 'pastSurveysTable')"
class="btn d-flex flex-row gap-2 align-items-center py-2"
>
<div>
<i class="fa-solid fa-flag-checkered fa-lg"></i>
</div>
<span class="text-nowrap">{{ $p.t("coodle/inactive_surveys") }}</span>
</div>
<div
@click="switchToTab('survey')"
:class="getTabStylingClass(view === 'survey' && !survey?.id)"
class="btn d-flex flex-row gap-2 align-items-center py-2"
>
<div>
<i class="fa-solid fa-circle-plus fa-lg"></i>
</div>
<span class="text-nowrap">{{ $p.t("coodle/create_survey") }}</span>
</div>
<div
@click="switchToTab('freeBusySettings')"
:class="getTabStylingClass(view === 'freeBusySettings')"
class="btn d-flex flex-row gap-2 align-items-center py-2"
>
<div>
<i class="fa-solid fa-gear fa-lg"></i>
</div>
<span class="text-nowrap">{{ $p.t("coodle/freebusy_settings") }}</span>
</div>
<div
@click="switchToTab('ical')"
:class="getTabStylingClass(view === 'ical')"
class="btn d-flex flex-row gap-2 align-items-center py-2"
>
<div>
<i class="fa-solid fa-calendar fa-lg"></i>
</div>
<span class="text-nowrap">{{ "Coodle iCal" }}</span>
</div>
</div>
</div>
<div class="flex-grow-1 mt-1">
<active-coodle-surveys
v-if="view === 'activeSurveysTable'"
@showSurveyDetails="showSurveyDetails($event.surveyId)"
/>
<inactive-coodle-surveys
v-else-if="view === 'pastSurveysTable'"
@showSurveyDetails="showSurveyDetails($event.surveyId)"
/>
<coodle-survey
v-else-if="view === 'survey'"
@surveyCreationCanceled="switchToTab('activeSurveysTable')"
@surveyCreated="afterSurveyCreated($event.surveyId, $event.shouldInformParticipants)"
@surveyUpdated="afterSurveyUpdated($event.surveyId, $event.shouldInformParticipants)"
@surveyCanceled="afterSurveyCanceled($event.surveyId, $event.shouldInformParticipants)"
@surveyCompleted="
afterSurveyCompleted($event.surveyId, $event.shouldInformParticipants, $event.selectedRoomId)
"
:survey="survey"
:authInfo="authInfo"
/>
<coodle-free-busy v-else-if="view === 'freeBusySettings'" :authUid="authInfo?.uid" />
<coodle-ical v-else-if="view === 'ical'" :authUid="authInfo?.uid" />
</div>
</div>`,
};
@@ -0,0 +1,73 @@
import CoodleSurvey from "./Components/CoodleSurvey.js";
import CoodleApi from "../../../api/factory/coodle.js";
export default {
name: "CoodleExternal",
components: { CoodleSurvey },
data() {
return {
survey: null,
authExternalParticipantId: null,
};
},
methods: {
async fetchSurvey() {
const surveyResponse = await this.$api.call(
CoodleApi.getSurveyForExternalParticipant(
this.$route.params.surveyId,
this.$route.params.accessKey,
),
);
if (surveyResponse.meta.status === "success") {
this.survey = this.parseIncomingSurveyData(surveyResponse.data);
this.authExternalParticipantId = this.survey.externalParticipants.find(
(externalParticipant) => !!externalParticipant.id,
).id;
}
},
parseIncomingSurveyData(surveyData) {
return {
id: surveyData.id,
creator: surveyData.creator,
title: surveyData.title,
description: surveyData.description,
timeslotDuration: surveyData.timeslot_duration,
maxSelections: surveyData.max_selections,
areParticipantsAnonymized:
surveyData.are_participants_anonymized,
areSelectionsAnonymized: surveyData.are_selections_anonymized,
selectedTimeslotId: surveyData.selected_timeslot_id,
endsAt: surveyData.ends_at.split(" ")[0],
completedAt: surveyData.completed_at,
canceledAt: surveyData.canceled_at,
createdAt: surveyData.created_at.split(" ")[0],
updatedAt: surveyData.updated_at.split(" ")[0],
timeslots: surveyData.timeslots.map((timeslot) => {
return {
id: timeslot.id,
startsAt: timeslot.starts_at,
};
}),
participants: surveyData.participants,
externalParticipants: surveyData.external_participants,
voteTallies: surveyData.vote_tallies,
};
},
},
async created() {
await this.fetchSurvey();
},
template: /*html*/ `
<div>
<coodle-survey
@surveyUpdated="fetchSurvey()"
:survey="survey"
:authInfo="null"
:authExternalParticipantId="authExternalParticipantId"
:isExternal="true"
/>
</div>
`,
};
+43 -7
View File
@@ -18,7 +18,8 @@ export default {
logoutUrl: String,
selectedtypes: Array,
searchbaroptions: Object,
searchfunction: Function
searchfunction: Function,
isExternal: Boolean,
},
data: () => {
return {
@@ -104,7 +105,9 @@ export default {
},
},
created(){
this.fetchMenu();
if (!this.$props.isExternal) {
this.fetchMenu();
}
},
mounted(){
this.$p.loadCategory(['ui', 'global', 'profilUpdate'])
@@ -113,17 +116,17 @@ export default {
});
},
template: /*html*/`
<div id="cis-header-bar" class="d-flex flex-row flex-grow-1">
<div id="nav-logo" class="d-none d-lg-block">
<div id="cis-header-bar" :class="{'justify-content-between': $props.isExternal}" class="d-flex flex-row flex-grow-1">
<div id="nav-logo" :class="{'d-none d-lg-block': !$props.isExternal}">
<div class="d-flex h-100 justify-content-between">
<a :href="rootUrl">
<img :src="logoUrl" alt="Corporate Identity Logo">
</a>
</div>
</div>
<div
v-if="isNarrow"
v-if="isNarrow && !$props.isExternal"
:class="{'collapse multi-collapse collapse-horizontal show': isMobile}"
id="navbar-toggler-collapsible"
>
@@ -135,6 +138,7 @@ export default {
</div>
<fhc-searchbar
v-if="!$props.isExternal"
:searchoptions="searchbaroptions"
:searchfunction="searchfunction"
ref="searchbar"
@@ -159,6 +163,7 @@ export default {
</fhc-searchbar>
<div
v-if="!$props.isExternal"
id="options-collapsible"
:class="{'collapse multi-collapse collapse-horizontal show': isMobile}"
>
@@ -184,9 +189,40 @@ export default {
</div>
</div>
</div>
<div v-if="$props.isExternal" class="d-flex flex-row align-items-center justify-content-center gap-2 pe-2">
<theme-switch></theme-switch>
<div>
<span
id="externalPageLanguageDropdownToggler"
data-bs-toggle="collapse"
data-bs-target="#externalPageLanguageDropdown"
aria-expanded="false"
aria-controls="externalPageLanguageDropdown"
class="fhc-primary-highlight-bg align-self-center btn btn-secondary rounded-5"
>
<i class="fa-solid fa-language fa-2xl"></i>
</span>
<div
id="externalPageLanguageDropdown"
ref="externalPageLanguageDropdown"
class="top-100 end-0 collapse position-absolute"
aria-labelledby="externalPageLanguageDropdownToggler"
>
<cis-sprachen />
</div>
</div>
</div>
</div>
<nav id="nav-main" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<nav
v-if="!$props.isExternal"
id="nav-main"
class="offcanvas offcanvas-start"
tabindex="-1"
aria-labelledby="nav-main-btn"
data-bs-backdrop="false"
>
<div id="nav-main-sticky">
<div class="d-flex flex-row h-100">
<div class="offcanvas-body p-0">
@@ -0,0 +1,61 @@
export default {
props: {
event: {
type: Object,
required: true,
},
},
emits: ["deleteCoodleTimeslot"],
computed: {
tooltipString() {
const tooltipArray = [];
const dateFragments = this.$props.event.datum.split("-");
tooltipArray.push(
dateFragments[2] +
"." +
dateFragments[1] +
"." +
dateFragments[0] +
".",
);
tooltipArray.push(this.start + "-" + this.end);
return tooltipArray.join("\n");
},
start() {
return luxon.Duration.fromISOTime(this.event.beginn).toISOTime({
suppressSeconds: true,
});
},
end() {
return luxon.Duration.fromISOTime(this.event.ende).toISOTime({
suppressSeconds: true,
});
},
},
template: /* html */ `
<div
class="coodle-calendar-event calendar-event-default h-100 w-100 p-1 d-flex flex-row"
v-tooltip="tooltipString"
>
<div
v-if="event?.beginn && event?.ende"
class="d-flex flex-column justify-content-center align-items-center flex-grow-1"
>
<span>{{ start }}</span>
<span>{{ end }}</span>
</div>
<div
class="d-flex justify-content-end align-items-start"
>
<div
@click="$emit('deleteCoodleTimeslot', { timeslot: $props.event })"
class="coodle-calendar-event-clear"
>
<i class="fa-solid fa-xmark fa-lg"></i>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,23 @@
export default {
props:{
event: {
type: Object,
required: true,
},
},
data() {
return {
dateObject: null,
};
},
created() {
this.dateObject = new Date(this.$props.event.isostart);
},
template: `
<div>
<span>{{ $props.event.datum }}</span>
<span>{{ $props.event.beginn.slice(0,5) }}</span>
<span>{{ $props.event.end.slice(0,5) }}</span>
</div>`,
}
@@ -0,0 +1,11 @@
export default {
props:{
event: {
type: Object,
required: true,
}
},
template:`
<div>Coodle</div>
`
}
@@ -0,0 +1,14 @@
export default {
props: {
event: {
type: Object,
required: true,
},
},
template: /* html */ `
<div
class="coodle-calendar-event calendar-event-default h-100 w-100 p-1 d-flex flex-row"
v-tooltip="tooltipString"
></div>
`,
};
@@ -0,0 +1,23 @@
export default {
props:{
event: {
type: Object,
required: true,
},
},
data() {
return {
dateObject: null,
};
},
created() {
this.dateObject = new Date(this.$props.event.isostart);
},
template: `
<div>
<span>{{ $props.event.datum }}</span>
<span>{{ $props.event.beginn.slice(0,5) }}</span>
<span>{{ $props.event.end.slice(0,5) }}</span>
</div>`,
}
@@ -0,0 +1,11 @@
export default {
props:{
event: {
type: Object,
required: true,
}
},
template:`
<div>FreeBusy</div>
`
}
@@ -206,6 +206,8 @@ export default {
});
this.lehrveranstaltungen.forEach((lehrveranstaltung)=>{
// a lehrveranstaltung without a modul has no children
if(!Array.isArray(lehrveranstaltung.lehrveranstaltungen)) return;
lehrveranstaltung.lehrveranstaltungen.sort((lv1,lv2)=>{
if (lv1.bezeichnung.toLowerCase() > lv2.bezeichnung.toLowerCase()) {
return 1;
@@ -393,6 +395,16 @@ export default {
</ul>
</div>
</div>
<div class="card" v-else-if="!Array.isArray(lehrveranstaltung.lehrveranstaltungen)">
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="d-flex list-group-item">
<a class="fhc-link-color d-block me-auto" href="#" @click="openLvUebersicht(lehrveranstaltung)">{{isGermanLanguage ? lehrveranstaltung.bezeichnung : lehrveranstaltung.bezeichnung_english}}</a>
<p>{{lehrveranstaltung.lehrform_kurzbz}}</p>
</li>
</ul>
</div>
</div>
</template>
</div>
@@ -255,7 +255,7 @@ export default {
deleteButton.className = 'btn btn-outline-secondary';
const icon = document.createElement('i');
icon.className = 'fa fa-trash';
icon.title = this.$p.t('ui/loeschen');
icon.title = this.$p.t('lehre/info_deleteMarkEntry');
deleteButton.append(icon);
deleteButton.addEventListener('click', evt => {
evt.stopPropagation();
+2 -1
View File
@@ -1119,7 +1119,7 @@ export const CoreFilterCmpt = {
<a aria-label="filter" v-if="filterActive" class="btn btn-link px-0 fhc-text" :title="$p.t('ui','filterdelete')" @click="clearFilters">
<span class="fa-solid fa-xl fa-filter-circle-xmark"></span>
</a>
<a aria-label="filter" href="#" class="btn btn-link px-0 fhc-text" data-bs-toggle="collapse" :data-bs-target="'#collapseColumns' + idExtra">
<a v-if="!noColumnFilter" aria-label="filter" href="#" class="btn btn-link px-0 fhc-text" data-bs-toggle="collapse" :data-bs-target="'#collapseColumns' + idExtra">
<span class="fa-solid fa-xl fa-table-columns"></span>
</a>
<a v-if="$props.isUsingPresets && $props.presetsId?.length" aria-label="filter" href="#" class="btn btn-link px-0 fhc-text" data-bs-toggle="collapse" :data-bs-target="'#tablePresets' + idExtra">
@@ -1153,6 +1153,7 @@ export const CoreFilterCmpt = {
></table-presets>
<filter-columns
v-if="!noColumnFilter"
:id="'collapseColumns' + idExtra"
class="card-body collapse"
:data-bs-parent="'#filterCollapsables' + idExtra"
+4 -1
View File
@@ -9,10 +9,13 @@ function saveAddClickListener(el, source, value) {
const listener = evt => {
evt.preventDefault();
evt.stopPropagation();
if (typeof value === "function") {
value = value(evt);
}
const customEvent = new CustomEvent('cal-click', {
cancelable: true,
bubbles: true,
detail: { source, value }
detail: { source, value },
});
evt.target.dispatchEvent(customEvent);
}
+20 -18
View File
@@ -1,4 +1,3 @@
// HELPER FILE -- that contains multiple functions which create, handle and manipulate js dates
// custom Error class for DateHelpers.js misuse
@@ -10,21 +9,25 @@ class DateHelperError extends Error {
}
/**
* adds padding to a number
* adds padding to a number
*
* @param {number|string} number - the number on which padding should be added.
* @returns {string} number with padding.
*/
export function numberPadding(number) {
if(typeof number !== "string" && typeof number !== "number")
{
throw new TypeError("function numberPadding in file DateHelpers.js is only usable with strings or numbers");
if (typeof number !== "string" && typeof number !== "number") {
throw new TypeError(
"function numberPadding in file DateHelpers.js is only usable with strings or numbers",
);
}
if(number.toString().length > 2)
{
throw new DateHelperError("The number on which the padding should be added should not be longer than to 2 characters, please refere to the function numberPadding in the helper file DateHelpers.js");
if (number.toString().length > 2) {
throw new DateHelperError(
"The number on which the padding should be added should not be longer than to 2 characters, please refere to the function numberPadding in the helper file DateHelpers.js",
);
}
return number.toString().length == 1 ? '0' + number.toString() : number.toString();
return number.toString().length == 1
? "0" + number.toString()
: number.toString();
}
/**
@@ -35,27 +38,26 @@ export function numberPadding(number) {
*/
export function formatDate(d) {
// parameter is of type Date
if(d instanceof Date)
{
if (d instanceof Date) {
if (isNaN(date.valueOf())) {
return 'N/A';
return "N/A";
}
// if the date is an invalid string then creating a date from the string will fail and N/A is returned
return `${numberPadding(d.getDate())}.${numberPadding(d.getMonth() + 1)}.${d.getFullYear()}`;
}
// parameter is of type string
else if (typeof d === "string")
{
else if (typeof d === "string") {
let date = new Date(d);
// if the date is an invalid string then creating a date from the string will fail and N/A is returned
if (isNaN(date.valueOf())) {
return 'N/A';
return "N/A";
}
return `${numberPadding(date.getDate())}.${numberPadding(date.getMonth() + 1)}.${date.getFullYear()}`;
}
// parameter is not of type string or Date and an exception is thrown
else
{
throw new TypeError("The parameter provided for this function is not a string or a Date object, please refere to the function formatDate in the DateHelpers.js file");
else {
throw new TypeError(
"The parameter provided for this function is not a string or a Date object, please refere to the function formatDate in the DateHelpers.js file",
);
}
}
+321
View File
@@ -0,0 +1,321 @@
<?php
/* Copyright (C) 2026 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 2 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, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
*
* Authors: Christian Paminger <[email protected]>,
* Andreas Oesterreicher <[email protected]>
*
* Beschreibung:
* Dieses Skript prueft die gesamte Systemumgebung und sollte nach jedem Update gestartet werden.
* Geprueft wird: die Datenbank per "dbupdate_VERSION.php" auf aktualitaet, dabei werden fehlende Attribute angelegt.
*/
require_once('../config/system.config.inc.php');
require_once('../include/basis_db.class.php');
$db = new basis_db();
$todayDateTime = new DateTime();
$today = $todayDateTime->format("Y-m-d");
$sixMonthsAgo = $todayDateTime->modify("-6 months")->format("Y-m-d");
// check if new coodle table is already populated
$newSurveysCountQuery = "SELECT COUNT (*) FROM campus.tbl_coodle_surveys";
$newSurveysCountResult = $db->db_query($newSurveysCountQuery);
$newSurveysCount = $db->db_fetch_object($newSurveysCountResult)->count;
if ($newSurveysCount > 0) {
echo ("ERROR: Coodle data migration not possible, new Coodle table is already populated!");
exit;
}
// getting old surveys...
$surveysQuery = "SELECT * FROM campus.tbl_coodle";
$surveysResult = $db->db_query($surveysQuery);
while ($survey = $db->db_fetch_object($surveysResult)) {
// getting old timeslots before importing survey, as timeslot count affects max_selections value
$timeslotsData = [];
$selectedOldTimeslotId = null;
$timeslotsQuery = "SELECT * FROM campus.tbl_coodle_termin WHERE coodle_id = $survey->coodle_id";
$timeslotsResult = $db->db_query($timeslotsQuery);
while ($timeslot = $db->db_fetch_object($timeslotsResult)) {
$timeslotsData[] = [
"oldTimeslotId" => $timeslot->coodle_termin_id,
"startTime" => $timeslot->datum . " " . $timeslot->uhrzeit,
];
if ($timeslot->auswahl) {
$selectedOldTimeslotId = $timeslot->coodle_termin_id;
}
}
// importing survey...
$title = formatTitle($survey->titel);
$formattedDescription = formatDescription($survey->beschreibung ?? "");
$timeslotDuration = $survey->dauer ? $survey->dauer : 15;
if ($timeslotDuration % 5 !== 0) {
$timeslotDuration = $timeslotDuration - ($timeslotDuration % 5);
}
$areParticipantsAnonymized = $survey->teilnehmer_anonym ? "true" : "false";
$areSelectionsAnonymized = $survey->termine_anonym ? "true" : "false";
$maxSelections = count($timeslotsData) ? count($timeslotsData) : 1;
$completedAt = $survey->coodle_status_kurzbz === "abgeschlossen" ? "'$survey->updateamum'" : "NULL";
$canceledAt = $survey->coodle_status_kurzbz === "storniert" ? "'$survey->updateamum'" : "NULL";
$endsAt = $survey->endedatum ? "'$survey->endedatum'" : "NOW()";
$createdAt = $survey->insertamum ? "'$survey->insertamum'" : "NOW()";
$updatedAt = $survey->updateamum ? "'$survey->updateamum'" : "NOW()";
if (in_array($survey->coodle_status_kurzbz, ["neu", "laufend"])) {
$createdAtDate = explode(" ", $survey->insertamum)[0];
$futureTimeslots = array_filter(
$timeslotsData,
function ($timeslot) use ($today) {
$timeslotDate = explode(" ", $timeslot["startTime"])[0];
return $timeslotDate >= $today;
}
);
if ($createdAtDate < $sixMonthsAgo && !count($futureTimeslots)) {
$canceledAt = "NOW()";
}
}
$surveyInsertValues = [
"id" => $survey->coodle_id,
"creator_uid" => "'$survey->ersteller_uid'",
"title" => "'$title'",
"description" => "'$formattedDescription'",
"timeslot_duration" => $timeslotDuration,
"are_selections_anonymized" => $areSelectionsAnonymized,
"are_participants_anonymized" => $areParticipantsAnonymized,
"max_selections" => $maxSelections,
"selected_timeslot_id" => "NULL",
"ends_at" => $endsAt,
"completed_at" => $completedAt,
"canceled_at" => $canceledAt,
"created_at" => $createdAt,
"updated_at" => $updatedAt,
];
$columnsString = "(" . implode(", ", array_keys($surveyInsertValues)) . ")";
$valuesString = "(" . implode(", ", array_values($surveyInsertValues)) . ")";
$surveyInsertQuery = "INSERT INTO campus.tbl_coodle_surveys $columnsString
VALUES $valuesString";
$db->db_query($surveyInsertQuery);
// importing timeslots...
$timeslotIds = [];
if (count($timeslotsData)) {
$timeslotInsertValues = array_map(
function ($timeslot) use ($survey) {
return "(" . $survey->coodle_id . ", '" . $timeslot["startTime"] . "')";
},
$timeslotsData
);
$timeslotInsertValues = implode(", ", $timeslotInsertValues);
$timeslotsInsertQuery = "INSERT INTO campus.tbl_coodle_survey_timeslots (survey_id, starts_at) VALUES $timeslotInsertValues";
$db->db_query($timeslotsInsertQuery);
$newTimeslotsQuery = "SELECT * FROM campus.tbl_coodle_survey_timeslots WHERE survey_id = $survey->coodle_id";
$newTimeslotsResult = $db->db_query($newTimeslotsQuery);
while ($newTimeslot = $db->db_fetch_object($newTimeslotsResult)) {
$correspondingOldTimeslotArray = array_values(array_filter(
$timeslotsData,
function ($oldTimeslot) use ($newTimeslot) {
return $oldTimeslot["startTime"] === $newTimeslot->starts_at;
}
));
$correspondingOldTimeslot = count($correspondingOldTimeslotArray) ? $correspondingOldTimeslotArray[0] : null;
$timeslotIds[] = [
"oldTimeslotId" => isset($correspondingOldTimeslot["oldTimeslotId"]) ? $correspondingOldTimeslot["oldTimeslotId"] : null,
"newTimeslotId" => intval($newTimeslot->id),
];
}
}
// if there is a selected timeslot, getting its new id and updating survey
if ($selectedOldTimeslotId && count($timeslotIds)) {
$selectedTimeslotIdArray = array_values(array_filter(
$timeslotIds,
function ($timeslot) use ($selectedOldTimeslotId) {
return $timeslot["oldTimeslotId"] === $selectedOldTimeslotId;
}
));
$selectedTimeslotId = count($selectedTimeslotIdArray) ? $selectedTimeslotIdArray[0] : null;
$selectedTimeslotNewId = isset($selectedTimeslotId["newTimeslotId"]) ? $selectedTimeslotId["newTimeslotId"] : null;
if ($selectedTimeslotNewId) {
$surveyUpdateQuery = "UPDATE campus.tbl_coodle_surveys
SET selected_timeslot_id = $selectedTimeslotNewId
WHERE id = $survey->coodle_id";
$db->db_query($surveyUpdateQuery);
}
}
// getting old timeslot votes...
$votes = [];
$oldTimeslotIds = array_map(
function ($timeslotId) {
return $timeslotId["oldTimeslotId"];
},
$timeslotIds
);
$oldTimeslotIds = array_filter(
$oldTimeslotIds,
function ($oldTimeslotId) {
return !!$oldTimeslotId;
}
);
if (count($oldTimeslotIds)) {
$oldTimeslotIdsString = "(" . implode(", ", $oldTimeslotIds) . ")";
$resourceTimeslotQuery = "SELECT * FROM campus.tbl_coodle_ressource_termin WHERE coodle_termin_id IN $oldTimeslotIdsString";
$resourceTimeslotResult = $db->db_query($resourceTimeslotQuery);
while ($resourceTimeslot = $db->db_fetch_object($resourceTimeslotResult)) {
$timeslotIdArray = array_values(array_filter($timeslotIds, function ($timeslotId) use ($resourceTimeslot) {
return $timeslotId["oldTimeslotId"] === $resourceTimeslot->coodle_termin_id;
}));
$timeslotId = count($timeslotIdArray) ? $timeslotIdArray[0] : null;
if ($timeslotId) {
$votes[] = [
"oldResourceId" => $resourceTimeslot->coodle_ressource_id,
"newTimeslotId" => $timeslotId["newTimeslotId"],
];
}
}
}
// getting participant resources...
$participantsData = [];
$externalParticipantsData = [];
$resourcesQuery = "SELECT * FROM campus.tbl_coodle_ressource WHERE coodle_id = $survey->coodle_id";
$resourcesResult = $db->db_query($resourcesQuery);
while ($resource = $db->db_fetch_object($resourcesResult)) {
if ($resource->uid) {
$participantData = [
"uid" => $resource->uid,
"selection" => null,
];
$participantVotes = array_values(array_filter(
$votes,
function ($vote) use ($resource) {
return $vote["oldResourceId"] === $resource->coodle_ressource_id;
}
));
if (count($participantVotes)) {
$participantData["selection"] = array_map(
function ($vote) {
return $vote["newTimeslotId"];
},
$participantVotes
);
}
$participantsData[] = $participantData;
} else if ($resource->name && $resource->email && filter_var($resource->email, FILTER_VALIDATE_EMAIL) && $resource->zugangscode) {
$externalParticipantData = [
"name" => escapeSingleQuotes($resource->name),
"email" => escapeSingleQuotes($resource->email),
"accessKey" => $resource->zugangscode,
"selection" => null,
];
$externalParticipantVotes = array_values(array_filter(
$votes,
function ($vote) use ($resource) {
return $vote["oldResourceId"] === $resource->coodle_ressource_id;
}
));
if (count($externalParticipantVotes)) {
$externalParticipantData["selection"] = array_map(
function ($vote) {
return $vote["newTimeslotId"];
},
$externalParticipantVotes
);
}
$externalParticipantsData[] = $externalParticipantData;
}
}
if (count($participantsData)) {
$participantInsertValues = array_map(
function ($participantData) use ($survey) {
$formattedSelectionString = $participantData["selection"] !== null ? ("'" . json_encode($participantData["selection"]) . "'") : "NULL";
if (strlen($formattedSelectionString) > 1000) {
$formattedSelectionString = "NULL";
}
$participantUid = $participantData["uid"];
return "($survey->coodle_id, '$participantUid', $formattedSelectionString)";
},
$participantsData
);
$participantInsertValues = implode(", ", $participantInsertValues);
$participantInsertQuery = "INSERT INTO campus.tbl_coodle_survey_participants (survey_id, participant_uid, selection) VALUES $participantInsertValues";
$db->db_query($participantInsertQuery);
}
if (count($externalParticipantsData)) {
$externalParticipantInsertValues = array_map(
function ($externalParticipantData) use ($survey) {
$formattedSelectionString = $externalParticipantData["selection"] !== null ? ("'" . json_encode($externalParticipantData["selection"]) . "'") : "NULL";
if (strlen($formattedSelectionString) > 1000) {
$formattedSelectionString = "NULL";
}
$externalParticipantName = $externalParticipantData["name"];
$externalParticipantEmail = $externalParticipantData["email"];
$externalParticipantAccessKey = $externalParticipantData["accessKey"];
return "($survey->coodle_id, '$externalParticipantName', '$externalParticipantEmail', '$externalParticipantAccessKey', $formattedSelectionString)";
},
$externalParticipantsData
);
$externalParticipantInsertValues = implode(", ", $externalParticipantInsertValues);
$externalParticipantInsertQuery = "INSERT INTO campus.tbl_coodle_survey_external_participants (survey_id, name, email, access_key, selection) VALUES $externalParticipantInsertValues";
$db->db_query($externalParticipantInsertQuery);
}
}
// set id autoincrement start value
$lastSurveyQuery = "SELECT id FROM campus.tbl_coodle_surveys ORDER BY id DESC LIMIT 1";
$lastSurveyQueryResult = $db->db_query($lastSurveyQuery);
$lastSurvey = $db->db_fetch_object($lastSurveyQueryResult);
$lastSurveyId = $lastSurvey->id + 1;
$autoIncrementResetQuery = "ALTER SEQUENCE campus.seq_tbl_coodle_surveys_id RESTART WITH $lastSurveyId";
$db->db_query($autoIncrementResetQuery);
echo ("Coodle data migration successfully completed!");
function formatDescription($oldDescription)
{
$descriptionWithNewlines = preg_replace("/<br ?\/?>/i", "\n", $oldDescription);
$descriptionWithoutHtmlTags = preg_replace("/<.*\/>/i", "", $descriptionWithNewlines);
$descriptionWithoutHtmlTags = preg_replace("/<.*>/i", " ", $descriptionWithNewlines);
$trimmedDescription = trim($descriptionWithoutHtmlTags);
$slicedDescription = substr($trimmedDescription, 0, 1000);
$retrimmedDescription = trim($slicedDescription);
return escapeSingleQuotes($retrimmedDescription);
}
function formatTitle($oldTitle)
{
return escapeSingleQuotes(trim(substr(trim($oldTitle), 0, 255)));
}
function escapeSingleQuotes($string)
{
return str_replace("'", "''", $string);
}
?>
+6 -1
View File
@@ -101,6 +101,7 @@ require_once('dbupdate_3.4/75959_StudVw_Automatische_Tags.php');
require_once('dbupdate_3.4/76160_lvv_favorites.php');
require_once('dbupdate_3.4/77080_tabulator_presets_table.php');
require_once('dbupdate_3.4/78292_gehaltsanpassungtyp.php');
require_once('dbupdate_3.4/77174_cis4_coodle_tables.php');
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
echo '<H2>Pruefe Tabellen und Attribute!</H2>';
@@ -160,6 +161,10 @@ $tabellen=array(
"campus.tbl_coodle_termin" => array("coodle_termin_id","coodle_id","datum","uhrzeit","auswahl"),
"campus.tbl_coodle_ressource_termin" => array("coodle_ressource_id","coodle_termin_id","insertamum","insertvon"),
"campus.tbl_coodle_status" => array("coodle_status_kurzbz","bezeichnung"),
"campus.tbl_coodle_surveys" => array("id", "creator_uid", "title", "description", "timeslot_duration", "are_selections_anonymized", "are_participants_anonymized", "max_selections", "selected_timeslot_id", "ends_at", "completed_at", "canceled_at", "created_at", "updated_at"),
"campus.tbl_coodle_survey_participants" => array("survey_id", "participant_uid", "selection"),
"campus.tbl_coodle_survey_external_participants" => array("id", "name", "email", "selection"),
"campus.tbl_coodle_survey_timeslots" => array("id", "survey_id", "starts_at"),
"campus.tbl_dms" => array("dms_id","oe_kurzbz","dokument_kurzbz","kategorie_kurzbz"),
"campus.tbl_dms_kategorie" => array("kategorie_kurzbz","bezeichnung","beschreibung","parent_kategorie_kurzbz","oe_kurzbz","berechtigung_kurzbz"),
"campus.tbl_dms_kategorie_gruppe" => array("kategorie_kurzbz","gruppe_kurzbz","insertamum","insertvon"),
@@ -167,7 +172,7 @@ $tabellen=array(
"campus.tbl_erreichbarkeit" => array("erreichbarkeit_kurzbz","beschreibung","farbe"),
"campus.tbl_feedback" => array("feedback_id","betreff","text","datum","uid","lehrveranstaltung_id","updateamum","updatevon","insertamum","insertvon"),
"campus.tbl_freebusy" => array("freebusy_id","uid","freebusytyp_kurzbz","url","aktiv","bezeichnung","insertamum","insertvon","updateamum","updatevon"),
"campus.tbl_freebusytyp" => array("freebusytyp_kurzbz","bezeichnung","beschreibung","url_vorlage"),
"campus.tbl_freebusytyp" => array("freebusytyp_kurzbz","bezeichnung","beschreibung","url_vorlage","is_active"),
"campus.tbl_infoscreen" => array("infoscreen_id","bezeichnung","beschreibung","ipadresse"),
"campus.tbl_infoscreen_content" => array("infoscreen_content_id","infoscreen_id","content_id","gueltigvon","gueltigbis","insertamum","insertvon","updateamum","updatevon","refreshzeit","exklusiv"),
"campus.tbl_legesamtnote" => array("student_uid","lehreinheit_id","note","benotungsdatum","updateamum","updatevon","insertamum","insertvon"),
@@ -0,0 +1,188 @@
<?php
if (!defined('DB_NAME'))
exit('No direct script access allowed');
if (!$result = @$db->db_query("SELECT 1 FROM campus.tbl_coodle_surveys LIMIT 1")) {
$qry = "CREATE TABLE campus.tbl_coodle_surveys (
id int NOT NULL,
creator_uid varchar(33) NOT NULL,
title varchar(255) NOT NULL,
description varchar(1000) NOT NULL,
timeslot_duration int NOT NULL,
are_selections_anonymized boolean NOT NULL,
are_participants_anonymized boolean NOT NULL,
max_selections int NOT NULL,
selected_timeslot_id int,
ends_at timestamp NOT NULL,
completed_at timestamp,
canceled_at timestamp,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL,
CONSTRAINT tbl_coodle_surveys_id_pk PRIMARY KEY(id),
CONSTRAINT tbl_coodle_surveys_creator_uid_fk FOREIGN KEY (creator_uid) REFERENCES public.tbl_benutzer(uid)
);";
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_surveys: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>campus.tbl_coodle_surveys: table created';
$db->db_query('CREATE SEQUENCE IF NOT EXISTS campus.seq_tbl_coodle_surveys_id
INCREMENT BY 1
NO MAXVALUE
NO MINVALUE
CACHE 1;');
$db->db_query("ALTER TABLE campus.tbl_coodle_surveys ALTER COLUMN id SET DEFAULT nextval('campus.seq_tbl_coodle_surveys_id');");
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_surveys TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_surveys: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_surveys';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_surveys TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_surveys: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_surveys';
$qry = 'GRANT USAGE ON campus.seq_tbl_coodle_surveys_id TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_surveys: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_surveys';
$qry = 'GRANT USAGE ON campus.seq_tbl_coodle_surveys_id TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_surveys: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_surveys';
}
if (!$result = @$db->db_query("SELECT 1 FROM campus.tbl_coodle_survey_participants LIMIT 1")) {
$qry = "CREATE TABLE campus.tbl_coodle_survey_participants (
survey_id int NOT NULL,
participant_uid varchar(33) NOT NULL,
selection varchar(1000),
CONSTRAINT tbl_coodle_survey_participants_survey_id_participant_uid_pk PRIMARY KEY(survey_id, participant_uid),
CONSTRAINT tbl_coodle_survey_participants_survey_id_fk FOREIGN KEY (survey_id) REFERENCES campus.tbl_coodle_surveys(id),
CONSTRAINT tbl_coodle_survey_participants_participant_uid_fk FOREIGN KEY (participant_uid) REFERENCES public.tbl_benutzer(uid)
);";
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>campus.tbl_coodle_survey_participants: table created';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_participants TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_survey_participants';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_participants TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_survey_participants';
}
if (!$result = @$db->db_query("SELECT 1 FROM campus.tbl_coodle_survey_external_participants LIMIT 1")) {
$qry = "CREATE TABLE campus.tbl_coodle_survey_external_participants (
id int NOT NULL,
survey_id int NOT NULL,
name varchar(255) NOT NULL,
email varchar(255) NOT NULL,
access_key varchar(64),
selection varchar(1000),
CONSTRAINT tbl_coodle_surveys_external_participants_id_pk PRIMARY KEY(id),
CONSTRAINT tbl_coodle_survey_external_participants_survey_id_fk FOREIGN KEY (survey_id) REFERENCES campus.tbl_coodle_surveys(id)
);";
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_external_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>campus.tbl_coodle_survey_external_participants: table created';
$db->db_query('CREATE SEQUENCE IF NOT EXISTS campus.seq_tbl_coodle_survey_external_participants_id
INCREMENT BY 1
NO MAXVALUE
NO MINVALUE
CACHE 1;');
$db->db_query("ALTER TABLE campus.tbl_coodle_survey_external_participants ALTER COLUMN id SET DEFAULT nextval('campus.seq_tbl_coodle_survey_external_participants_id');");
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_external_participants TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_external_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_survey_external_participants';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_external_participants TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_external_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_survey_external_participants';
$qry = 'GRANT USAGE ON campus.seq_tbl_coodle_survey_external_participants_id TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_external_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_survey_external_participants';
$qry = 'GRANT USAGE ON campus.seq_tbl_coodle_survey_external_participants_id TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_external_participants: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_survey_external_participants';
}
if (!$result = @$db->db_query("SELECT 1 FROM campus.tbl_coodle_survey_timeslots LIMIT 1")) {
$qry = "CREATE TABLE campus.tbl_coodle_survey_timeslots (
id int GENERATED ALWAYS AS IDENTITY,
survey_id int NOT NULL,
starts_at timestamp NOT NULL,
CONSTRAINT tbl_coodle_survey_timeslots_id_pk PRIMARY KEY(id),
CONSTRAINT tbl_coodle_survey_timeslots_survey_id_fk FOREIGN KEY (survey_id) REFERENCES campus.tbl_coodle_surveys(id)
);";
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_timeslots: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>campus.tbl_coodle_survey_timeslots: table created';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_timeslots TO web;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_timeslots: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>web</strong> on campus.tbl_coodle_survey_timeslots';
$qry = 'GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE campus.tbl_coodle_survey_timeslots TO vilesci;';
if (!$db->db_query($qry))
echo '<strong>campus.tbl_coodle_survey_timeslots: ' . $db->db_last_error() . '</strong><br>';
else
echo '<br>Granted privileges to <strong>vilesci</strong> on campus.tbl_coodle_survey_timeslots';
}
if (!$result = @$db->db_query("SELECT is_active FROM campus.tbl_freebusytyp LIMIT 1")) {
$qry = "ALTER TABLE campus.tbl_freebusytyp ADD is_active boolean NOT NULL DEFAULT TRUE;";
if (!$db->db_query($qry))
echo '<strong>campus.tbl_freebusytyp: ' . $db->db_last_error() . '</strong><br>';
else {
echo '<br>campus.tbl_freebusytyp: column is_active added';
$db->db_query("UPDATE campus.tbl_freebusytyp SET is_active = FALSE WHERE freebusytyp_kurzbz = 'Webmail';");
}
}
File diff suppressed because it is too large Load Diff