Merge branch 'feature-63435/Studierendenverwaltung_Studierende_Verbandsgruppen_und_Spezialgruppen_zuordnen_Multiaktion' into demo-cis40

This commit is contained in:
Harald Bamberger
2025-09-18 07:23:43 +02:00
25 changed files with 2023 additions and 291 deletions
@@ -110,7 +110,7 @@ class Config extends FHCAPI_Controller
];
$result['groups'] = [
'title' => $this->p->t('stv', 'tab_groups'),
'component' => './Stv/Studentenverwaltung/Details/Gruppen.js'
'component' => './Stv/Studentenverwaltung/Details/Groups.js'
];
$result['messages'] = [
'title' => $this->p->t('stv', 'tab_messages'),
@@ -205,6 +205,10 @@ class Config extends FHCAPI_Controller
'additionalCols' => []
]
];
$result['groups'] = [
'title' => $this->p->t('stv', 'tab_groups'),
'component' => './Stv/Studentenverwaltung/Details/Groups.js'
];
$result['status'] = [
'title' => 'Status',
'component' => './Stv/Studentenverwaltung/Details/MultiStatus.js',
@@ -9,6 +9,8 @@ class Gruppen extends FHCAPI_Controller
public function __construct()
{
parent::__construct([
'add' => ['admin:rw', 'assistenz:rw'],
'search' => ['admin:r', 'assistenz:r'],
'getGruppen' => ['admin:r', 'assistenz:r'],
'deleteGruppe' => ['admin:rw', 'assistenz:rw'],
]);
@@ -18,7 +20,9 @@ class Gruppen extends FHCAPI_Controller
// Load language phrases
$this->loadPhrases([
'ui', 'gruppenmanagement'
'ui',
'gruppenmanagement',
'lehre'
]);
// Load models
@@ -26,15 +30,140 @@ class Gruppen extends FHCAPI_Controller
$this->load->model('organisation/Gruppe_model', 'GruppeModel');
}
public function add()
{
$this->load->library("form_validation");
$this->form_validation->set_rules(
'gruppe_kurzbz',
$this->p->t('gruppenmanagement', 'gruppe'),
'required|is_in_db[organisation/Gruppe_model]',
[
'required' => $this->p->t('ui', 'error_fieldRequired'),
'is_in_db' => $this->p->t('ui', 'error_fieldNotFound')
]
);
$this->form_validation->set_rules(
'uid',
$this->p->t('ui', 'student_uid'),
'required|is_in_db[crm/Student_model:student_uid]',
[
'required' => $this->p->t('ui', 'error_fieldRequired'),
'is_in_db' => $this->p->t('ui', 'error_fieldNotFound')
]
);
$this->form_validation->set_rules(
'studiensemester_kurzbz',
$this->p->t('lehre', 'studiensemester'),
'required|is_in_db[organisation/Studiensemester_model]',
[
'required' => $this->p->t('ui', 'error_fieldRequired'),
'is_in_db' => $this->p->t('ui', 'error_fieldNotFound')
]
);
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$gruppe_kurzbz = $this->input->post('gruppe_kurzbz');
$uid = $this->input->post('uid');
$studiensemester_kurzbz = $this->input->post('studiensemester_kurzbz');
$result = $this->BenutzergruppeModel->load([
$gruppe_kurzbz,
$uid
]);
$benutzergruppe = $this->getDataOrTerminateWithError($result);
if ($benutzergruppe) {
$this->terminateWithError(
$this->p->t('gruppenmanagement', 'error_alreadyInGroup', [
'uid' => $uid,
'studiensemester_kurzbz' => current($benutzergruppe)->studiensemester_kurzbz
]),
self::ERROR_TYPE_GENERAL
);
}
$result = $this->BenutzergruppeModel->insert([
'uid' => $uid,
'gruppe_kurzbz' => $gruppe_kurzbz,
'studiensemester_kurzbz' => $studiensemester_kurzbz,
'insertamum' => date('c'),
'insertvon' => getAuthUID()
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess();
}
public function search()
{
$query = $this->input->post('query');
if (!$query)
$this->terminateWithSuccess([]);
// add query to where clause
$query = strtoupper($query);
$query = $this->GruppeModel->db->escape_like_str($query);
$query = '%' . str_replace(' ', '%', $query) . '%';
$this->GruppeModel->db->group_start();
$this->GruppeModel->db->or_like('UPPER(gruppe_kurzbz)', $query, 'none', false);
$this->GruppeModel->db->or_like('UPPER(bezeichnung)', $query, 'none', false);
$this->GruppeModel->db->or_like('UPPER(beschreibung)', $query, 'none', false);
$this->GruppeModel->db->group_end();
// add stg sorting 1
$studiengang_kz = $this->input->post('studiengang_kz');
$sort_stg = $studiengang_kz ? "WHEN studiengang_kz = " . $this->GruppeModel->escape($studiengang_kz) . " THEN 0" : "";
// add stg sorting 2
$studiengang_kzs = [];
$result = $this->permissionlib->getSTG_isEntitledFor('admin');
if ($result)
$studiengang_kzs = array_merge($studiengang_kzs, $result);
$result = $this->permissionlib->getSTG_isEntitledFor('assistenz');
if ($result)
$studiengang_kzs = array_merge($studiengang_kzs, $result);
// selects
$this->GruppeModel->addSelect("*");
$this->GruppeModel->addSelect("CASE
" . $sort_stg . "
WHEN studiengang_kz IN (" . implode(",", $this->GruppeModel->db->escape($studiengang_kzs)) . ")
THEN 1
ELSE 2
END AS sort_stg");
// ordering
$this->GruppeModel->addOrder("sort_stg");
$this->GruppeModel->addOrder("sort");
$this->GruppeModel->addOrder("gruppe_kurzbz");
// default where clause & execute
$result = $this->GruppeModel->loadWhere([
'lehre' => true,
'sichtbar' => true,
'aktiv' => true,
'direktinskription' => false
]);
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
public function getGruppen($student_uid)
{
$this->BenutzergruppeModel ->addSelect('gruppe_kurzbz');
$this->BenutzergruppeModel ->addSelect('bezeichnung');
$this->BenutzergruppeModel ->addSelect('generiert');
$this->BenutzergruppeModel ->addSelect('uid');
$this->BenutzergruppeModel ->addSelect('studiensemester_kurzbz');
$this->BenutzergruppeModel ->addJoin('public.tbl_gruppe', 'gruppe_kurzbz');
$this->BenutzergruppeModel-> addOrder('bezeichnung', 'ASC');
$this->BenutzergruppeModel->addSelect('gruppe_kurzbz');
$this->BenutzergruppeModel->addSelect('bezeichnung');
$this->BenutzergruppeModel->addSelect('generiert');
$this->BenutzergruppeModel->addSelect('uid');
$this->BenutzergruppeModel->addSelect('studiensemester_kurzbz');
$this->BenutzergruppeModel->addJoin('public.tbl_gruppe', 'gruppe_kurzbz');
$this->BenutzergruppeModel->addOrder('bezeichnung', 'ASC');
$result = $this->BenutzergruppeModel->loadWhere(
array(
@@ -49,29 +178,48 @@ class Gruppen extends FHCAPI_Controller
public function deleteGruppe()
{
$student_uid = $this->input->post('id');
$this->load->library("form_validation");
$this->form_validation->set_rules(
'uid',
$this->p->t('person', 'UID'),
'required',
[
'required' => $this->p->t('ui', 'error_fieldRequired')
]
);
$this->form_validation->set_rules(
'gruppe_kurzbz',
$this->p->t('gruppenmanagement', 'gruppe'),
'required',
[
'required' => $this->p->t('ui', 'error_fieldRequired')
]
);
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$uid = $this->input->post('uid');
$gruppe_kurzbz = $this->input->post('gruppe_kurzbz');
//Validate if automatic group generation
$result = $this->GruppeModel-> loadWhere(
array(
'gruppe_kurzbz' => $gruppe_kurzbz
)
);
// Validate if automatic group generation
$result = $this->GruppeModel->loadWhere([
'gruppe_kurzbz' => $gruppe_kurzbz
]);
$data = $this->getDataOrTerminateWithError($result);
$generation = current($data);
if($generation->generiert)
if ($generation->generiert)
{
$this->terminateWithError($this->p->t('gruppenmanagement', 'error_deleteGeneratedGroups'), self::ERROR_TYPE_GENERAL);
}
$result = $this->BenutzergruppeModel->delete(
array(
'gruppe_kurzbz' => $gruppe_kurzbz,
'uid' => $student_uid
)
);
$result = $this->BenutzergruppeModel->delete([
'gruppe_kurzbz' => $gruppe_kurzbz,
'uid' => $uid
]);
$data = $this->getDataOrTerminateWithError($result);
@@ -0,0 +1,63 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class Lehrverband extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'hasOrgforms' => ['admin:r', 'assistenz:r'],
'getTree' => ['admin:r', 'assistenz:r'],
'getSpecialgroups' => ['admin:r', 'assistenz:r']
]);
}
public function hasOrgforms($studiengang_kz)
{
$this->load->model('organisation/Studiengang_model', 'StudiengangModel');
$result = $this->StudiengangModel->load($studiengang_kz);
$data = $this->getDataOrTerminateWithError($result);
if ($data) {
$data = current($data)->mischform;
}
$this->terminateWithSuccess($data);
}
public function getTree($studiengang_kz)
{
$this->load->model('organisation/Lehrverband_model', 'LehrverbandModel');
$result = $this->LehrverbandModel->loadWhere([
'studiengang_kz' => $studiengang_kz,
'aktiv' => true
]);
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
public function getSpecialgroups($studiengang_kz)
{
$this->load->model('organisation/Gruppe_model', 'GruppeModel');
$where = [
'studiengang_kz' => $studiengang_kz,
'lehre' => true,
'sichtbar' => true,
'aktiv' => true,
'direktinskription' => false
];
$result = $this->GruppeModel->loadWhere($where);
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
}
@@ -36,6 +36,7 @@ class Student extends FHCAPI_Controller
parent::__construct([
'get' => ['admin:r', 'assistenz:r'],
'save' => ['admin:rw', 'assistenz:rw'],
'saveStudent' => ['admin:rw', 'assistenz:rw'],
'check' => ['admin:rw', 'assistenz:rw'],
'add' => ['admin:rw', 'assistenz:rw'] // TODO(chris): extra permissions
]);
@@ -414,6 +415,31 @@ class Student extends FHCAPI_Controller
), ''));
}
/**
* Saves data to a prestudent using their student_uid
*
* @param string $student_uid
* @param string $studiensemester_kurzbz
* @return void
*/
public function saveStudent($student_uid, $studiensemester_kurzbz)
{
$this->load->model('crm/Student_model', 'StudentModel');
$result = $this->StudentModel->load([$student_uid]);
$data = $this->getDataOrTerminateWithError($result);
if (!$data)
show_404(); // No Student with that ID
$student = current($data);
$this->checkPermissionsForPrestudent($student->prestudent_id, ['admin:rw', 'assistenz:rw']);
return $this->save($student->prestudent_id, $studiensemester_kurzbz);
}
public function check()
{
$this->load->library('form_validation');
@@ -272,6 +272,7 @@ class Verband extends FHCAPI_Controller
$this->StudiengangModel->addSelect("CONCAT(UPPER(CONCAT(typ, kurzbz)), '-', semester, verband, (SELECT CASE WHEN bezeichnung IS NULL OR bezeichnung='' THEN ''::TEXT ELSE CONCAT(' (', bezeichnung, ')') END FROM public.tbl_lehrverband WHERE studiengang_kz=v.studiengang_kz AND semester=v.semester AND verband=v.verband ORDER BY gruppe LIMIT 1)) AS name", false);
$this->StudiengangModel->addSelect("CASE WHEN MAX(gruppe)='' OR MAX(gruppe)=' ' THEN TRUE ELSE FALSE END AS leaf");
$this->StudiengangModel->addSelect($this->StudiengangModel->escape($semester) . ' AS semester');
$this->StudiengangModel->addSelect('verband');
$this->StudiengangModel->addSelect($this->StudiengangModel->escape($studiengang_kz) . '::integer AS stg_kz', false);
@@ -320,6 +321,8 @@ class Verband extends FHCAPI_Controller
$this->StudiengangModel->addSelect("CONCAT(UPPER(CONCAT(typ, kurzbz)), '-', semester, verband, gruppe, (SELECT CASE WHEN bezeichnung IS NULL OR bezeichnung='' THEN ''::TEXT ELSE CONCAT(' (', bezeichnung, ')') END FROM public.tbl_lehrverband WHERE studiengang_kz=v.studiengang_kz AND semester=v.semester AND verband=v.verband AND gruppe=v.gruppe ORDER BY gruppe LIMIT 1)) AS name", false);
$this->StudiengangModel->addSelect("TRUE AS leaf", false);
$this->StudiengangModel->addSelect('v.semester');
$this->StudiengangModel->addSelect('v.verband');
$this->StudiengangModel->addSelect('gruppe');
$this->StudiengangModel->addSelect($this->StudiengangModel->escape($studiengang_kz) . '::integer AS stg_kz', false);
+28
View File
@@ -515,3 +515,31 @@ function has_permissions_for_stg($studiengang_kz, $permissions = '')
return false;
}
/**
* check if an entry exists in the database
*/
function is_in_db($key, $model = '')
{
if (!$model)
return false;
$field = strstr($model, ":");
if ($field) {
$model = strstr($model, ":", true);
$field = substr($field, 1);
}
$CI =& get_instance();
$CI->load->model($model, $model);
if ($field) {
$result = $CI->$model->loadWhere([
$field => $key
]);
} else {
$result = $CI->$model->load($key);
}
return (isSuccess($result) && hasData($result));
}
@@ -41,3 +41,4 @@ if (!defined('BASEPATH')) exit('No direct script access allowed');
$lang['form_validation_has_write_permissions'] = 'You have no rights to edit {field} field.';
$lang['form_validation_is_valid_date'] = 'The date format is invalid or out of range.';
$lang['form_validation_has_permissions_for_stg'] = 'You have no rights for stg {field}.';
$lang['form_validation_is_in_db'] = '{field} does not exist.';
+29
View File
@@ -5690,3 +5690,32 @@
outline: 0;
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
}
/* input-group */
/* autocomplete */
.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) > .p-autocomplete-input,
.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control > .p-autocomplete-input,
.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select > .p-autocomplete-input,
.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > .p-autocomplete-input,
.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) > .p-autocomplete > .p-autocomplete-input,
.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control > .p-autocomplete > .p-autocomplete-input,
.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select > .p-autocomplete > .p-autocomplete-input,
.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > .p-autocomplete > .p-autocomplete-input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > .p-autocomplete-input,
.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > .p-autocomplete > .p-autocomplete-input {
margin-left: calc(var(--bs-border-width) * -1);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.p-inputtext.is-invalid:enabled:hover {
border-color: var(--bs-form-invalid-border-color);
}
.p-inputtext.is-invalid:enabled:focus,
.was-validated .p-inputtext:invalid:focus {
border-color: var(--bs-form-invalid-border-color);
box-shadow: 0 0 0 .25rem rgba(var(--bs-danger-rgb),.25);
}
+10
View File
@@ -35,4 +35,14 @@ export default {
params
};
},
saveStudent(student_uid, studiensemester_kurzbz, params) {
return {
method: 'post',
url: 'api/frontend/v1/stv/student/saveStudent/'
+ encodeURIComponent(student_uid)
+ '/'
+ encodeURIComponent(studiensemester_kurzbz),
params
};
}
};
+21
View File
@@ -16,6 +16,27 @@
*/
export default {
add(uid, gruppe_kurzbz, studiensemester_kurzbz) {
return {
method: 'post',
url: 'api/frontend/v1/stv/gruppen/add/',
params: {
uid,
gruppe_kurzbz,
studiensemester_kurzbz
}
};
},
search(query, studiengang_kz) {
return {
method: 'post',
url: 'api/frontend/v1/stv/gruppen/search/',
params: {
query,
studiengang_kz
}
};
},
getGruppen(id) {
return {
method: 'get',
+31
View File
@@ -0,0 +1,31 @@
/**
* Copyright (C) 2025 fhcomplete.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export default {
hasOrgforms(studiengang_kz) {
return {
method: 'get',
url: 'api/frontend/v1/stv/lehrverband/hasOrgforms/' + studiengang_kz
};
},
getTree(studiengang_kz) {
return {
method: 'get',
url: 'api/frontend/v1/stv/lehrverband/getTree/' + studiengang_kz
};
}
};
@@ -96,8 +96,11 @@ export default {
},
methods: {
onDragstart(evt) {
DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig);
this.draggedInternalEvent = evt.detail.item;
const data = DragAndDrop.convertToTransferData(evt.detail.item.orig);
if (DragAndDrop.isValidDragObject(data)) {
DragAndDrop.setTransferData(evt.detail.originalEvent, data);
this.draggedInternalEvent = evt.detail.item;
}
},
onDragend() {
this.draggedInternalEvent = null;
@@ -0,0 +1,216 @@
import GroupsLvb from './Groups/Lvb.js';
import GroupsSpecial from './Groups/Special.js';
import GroupsList from './Groups/List.js';
import ApiStvGroups from '../../../../api/factory/stv/group.js';
import ApiStvDetails from '../../../../api/factory/stv/details.js';
export default {
name: 'TabGroups',
components: {
GroupsLvb,
GroupsSpecial,
GroupsList
},
inject: {
$reloadList: {
from: '$reloadList',
required: true
},
currentSemester: {
form: 'currentSemester',
required: true
}
},
props: {
modelValue: [Object, Array]
},
data() {
return {
hasOrgforms: false,
lvbList: [],
specialGroups: [],
selectedOrgform: false,
selectedSemester: false,
selectedVerband: false,
selectedGruppe: false,
multiFormHandler: (form, errors) => {
function _split_errors(result, [uid, errors]) {
const gruppe_kurzbz = [];
const studiensemester_kurzbz = [];
const others = {};
errors.forEach(error => {
_split_messages(error.messages, gruppe_kurzbz, studiensemester_kurzbz, others);
});
if (gruppe_kurzbz.length) {
if (!result.formFeedback.gruppe_kurzbz)
result.formFeedback.gruppe_kurzbz = [];
result.formFeedback.gruppe_kurzbz.push(...gruppe_kurzbz);
}
if (studiensemester_kurzbz.length) {
if (!result.formFeedback.studiensemester_kurzbz)
result.formFeedback.studiensemester_kurzbz = [];
result.formFeedback.studiensemester_kurzbz.push(...studiensemester_kurzbz);
}
if (Object.keys(others).length) {
result.toast[uid] = [
{ type: 'validation', messages: others }
];
}
return result;
}
function _split_messages(messages, gruppe_kurzbz, studiensemester_kurzbz, others) {
Object.entries(messages).forEach(([field, msg]) => {
if (field == 'gruppe_kurzbz') {
gruppe_kurzbz.push(msg);
} else if (field == 'studiensemester_kurzbz') {
studiensemester_kurzbz.push(msg);
} else {
if (!others[field])
others[field] = [];
others[field].push(msg);
}
});
}
const { formFeedback, toast } = Object.entries(errors)
.reduce(_split_errors, { formFeedback: {}, toast: {} });
if (formFeedback.gruppe_kurzbz)
formFeedback.gruppe_kurzbz = formFeedback.gruppe_kurzbz
.filter((v,k,a) => a.indexOf(v) == k);
if (formFeedback.studiensemester_kurzbz)
formFeedback.studiensemester_kurzbz = formFeedback.studiensemester_kurzbz
.filter((v,k,a) => a.indexOf(v) == k);
form.clearValidation();
if (Object.keys(formFeedback)) {
form.setFeedback(false, formFeedback);
}
if (Object.keys(toast).length) {
console.log(toast);
this.$api.getErrorHandler().handler.toast(toast);
}
}
};
},
computed: {
allAreStudents() {
if (Array.isArray(this.modelValue))
return this.modelValue.every(ps => ps.uid);
return this.modelValue.uid;
},
sharedStg() {
if (Array.isArray(this.modelValue)) {
const first = this.modelValue.find(Boolean);
if (this.modelValue.every(ps => ps.studiengang_kz === first.studiengang_kz))
return first.studiengang_kz;
return undefined;
}
return this.modelValue.studiengang_kz;
}
},
methods: {
showNewGroupModal() {
this.$refs.newGroupModal.show()
},
changeLvb(params) {
let data = { semester: params.semester };
if (params.verband && params.verband != " ") {
data.verband = params.verband;
if (params.gruppe && params.gruppe != " ")
data.gruppe = params.gruppe;
}
let endpoint;
if (Array.isArray(this.modelValue)) {
endpoint = this.modelValue.map(student => [
student.uid + ' (' + student.vorname + ' ' + student.nachname + ')',
ApiStvDetails.save(
student.prestudent_id,
this.currentSemester,
data
)
]);
} else {
endpoint = ApiStvDetails.save(
this.modelValue.prestudent_id,
this.currentSemester,
data
);
}
this.$api
.call(endpoint)
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave'));
this.$reloadList();
})
.catch(this.$fhcAlert.handleSystemError);
},
addSpecialGroup() {
const gruppe_kurzbz = this.$refs.newGroupModal.value.gruppe_kurzbz || this.$refs.newGroupModal.value;
if (Array.isArray(this.modelValue)) {
this.$refs.newGroupModal.$refs.form
.call(
this.modelValue.map(student => [
student.uid + ' (' + student.vorname + ' ' + student.nachname + ')',
ApiStvGroups.add(
student.uid,
gruppe_kurzbz,
this.currentSemester
)
]),
{
errorHandling: {
combine: { form: ['validation'] },
handler: { form: this.multiFormHandler }
}
}
)
.then(result => {
const successes = result.filter(res => res.status == 'fulfilled');
if (result.length == successes.length) {
this.$refs.newGroupModal.hide();
}
if (successes.length) {
this.$fhcAlert.alertSuccess(this.$p.t('gruppenmanagement/groups_added', { n: successes.length }));
this.$refs.list.reload();
}
})
.catch(this.$fhcAlert.handleSystemError);
} else {
this.$refs.newGroupModal.$refs.form
.call(ApiStvGroups.add(this.modelValue.uid, gruppe_kurzbz, this.currentSemester))
.then(() => {
this.$refs.newGroupModal.hide();
this.$fhcAlert.alertSuccess(this.$p.t('gruppenmanagement/groups_added', { n: 1 }));
this.$refs.list.reload();
})
.catch(this.$fhcAlert.handleSystemError);
}
}
},
template: /* html */`
<div class="stv-details-groups h-100 d-flex flex-column">
<h3>{{ $p.t('gruppenmanagement/special_groups') }}</h3>
<groups-special
ref="newGroupModal"
:default-stg="sharedStg"
@submit.capture.prevent="addSpecialGroup"
/>
<groups-list
ref="list"
class="mb-3"
:students="modelValue"
@new="showNewGroupModal"
/>
<h3>{{ $p.t('lehre/lehrverband') }}</h3>
<groups-lvb
:students="modelValue"
@submit="changeLvb"
/>
</div>`
};
@@ -0,0 +1,171 @@
import { CoreFilterCmpt } from "../../../../filter/Filter.js";
import ApiStvGroups from '../../../../../api/factory/stv/group.js';
export default {
name: 'TabGroupsList',
components: {
CoreFilterCmpt
},
inject: [
"currentSemester"
],
props: {
students: Object
},
emits: [
"new"
],
data() {
return {
phrasenLoaded: false,
optionsReady: true
};
},
computed: {
tabulatorOptions() {
let ajaxRequestFunc, ajaxResponse, initialFilter;
if (Array.isArray(this.students)) {
ajaxRequestFunc = () => {
return this.$api.call(
this.students.map(student => ApiStvGroups.getGruppen(student.uid))
);
};
ajaxResponse = (url, params, response) => {
return response.reduce((data, result) => [
...data,
...result.value.data
], []);
};
initialFilter = [
this.students.map(student => {
return { field: "uid", type: "=", value: student.uid };
}),
[
{ field: "studiensemester_kurzbz", type: "=", value: null },
{ field: "studiensemester_kurzbz", type: "=", value: this.currentSemester }
]
];
} else {
ajaxRequestFunc = () => {
return this.$api.call(
ApiStvGroups.getGruppen(this.students.uid)
);
};
ajaxResponse = (url, params, response) => {
return response.data;
};
initialFilter = [
{ field: "uid", type: "=", value: this.students.uid },
[
{ field: "studiensemester_kurzbz", type: "=", value: null },
{ field: "studiensemester_kurzbz", type: "=", value: this.currentSemester }
]
];
}
return {
ajaxURL: 'dummy',
ajaxRequestFunc,
ajaxResponse,
initialFilter,
columns: [
{ title: this.$p.t('gruppenmanagement/gruppe'), field: "gruppe_kurzbz" },
{ title: this.$p.t('ui/bezeichnung'), field: "bezeichnung" },
{ title: this.$p.t('lehre/studiensemester'), field: "studiensemester_kurzbz" },
{
title: this.$p.t('gruppenmanagement/automatisch_generiert'),
field: "generiert",
formatter: "tickCross",
hozAlign: "center",
formatterParams: {
tickElement: '<i class="fa fa-check text-success"></i>',
crossElement: '<i class="fa fa-xmark text-danger"></i>'
}
},
{ title: this.$p.t('ui/student_uid'), field: "uid" },
{
title: this.$p.t('global/actions'),
field: 'actions',
minWidth: 150, // Ensures Action-buttons will be always fully displayed
formatter: (cell, formatterParams, onRendered) => {
const container = document.createElement('div');
container.className = "d-flex gap-2";
const data = cell.getData();
const button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-action';
button.innerHTML = '<i class="fa fa-xmark"></i>';
button.title = this.$p.t('ui', 'loeschen');
button.addEventListener('click', () =>
this.actionDeleteGroup(data)
);
if (data.generiert)
button.disabled = true;
container.append(button);
return container;
},
frozen: true
}
],
height: 'auto',
index: 'group_id',
persistenceID: 'stv-details-group-list'
};
}
},
watch: {
tabulatorOptions() {
// Refresh Tabulator if options have changed
this.optionsReady = false;
this.$nextTick(() => this.optionsReady = true);
}
},
methods: {
actionDeleteGroup(data) {
this.$fhcAlert
.confirmDelete()
.then(result => result
? data
: Promise.reject({handled: true}))
.then(this.deleteGroup)
.catch(this.$fhcAlert.handleSystemError);
},
deleteGroup(params) {
return this.$api
.call(ApiStvGroups.deleteGroup(params))
.then(response => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete'));
this.reload();
})
.catch(this.$fhcAlert.handleSystemError);
},
reload() {
this.$refs.table.reloadTable();
}
},
mounted() {
this.$p
.loadCategory(['global', 'lehre', 'ui', 'gruppenmanagement'])
.then(() => {
this.phrasenLoaded = true;
});
},
template: /* html */`
<div class="stv-details-groups-list">
<core-filter-cmpt
v-if="phrasenLoaded && optionsReady"
ref="table"
:tabulator-options="tabulatorOptions"
table-only
:side-menu="false"
reload
:reload-btn-infotext="$p.t('table/reload')"
new-btn-show
:new-btn-label="$p.t('lehre/gruppe')"
@click:new="$emit('new')"
>
</core-filter-cmpt>
</div>`
};
@@ -0,0 +1,241 @@
import FhcForm from "../../../../Form/Form.js";
import ApiStvLvb from '../../../../../api/factory/stv/lehrverband.js';
export default {
name: 'TabGroupsLvb',
components: {
FhcForm
},
props: {
students: Object
},
emits: [
"submit"
],
data() {
return {
lvbList: [],
selectedSemester: false,
selectedVerband: false,
selectedGruppe: false
};
},
computed: {
allAreStudents() {
if (Array.isArray(this.students))
return this.students.every(ps => ps.uid);
return this.students.uid;
},
studiengang_kz() {
if (Array.isArray(this.students)) {
const first = this.students.find(Boolean);
if (this.students.every(ps => ps.studiengang_kz === first.studiengang_kz))
return first.studiengang_kz;
return false;
}
return this.students.studiengang_kz;
},
semester: {
get() {
if (this.selectedSemester !== false) {
if (this.lvbList.some(item => item.semester == this.selectedSemester))
return this.selectedSemester;
return false;
}
if (Array.isArray(this.students)) {
const first = this.students.find(Boolean);
if (this.lvbList.every(item => item.semester != first.semester))
return false;
if (this.students.every(ps => ps.semester === first.semester))
return first.semester;
return false;
}
if (this.lvbList.some(item => item.semester == this.students.semester))
return this.students.semester;
return false;
},
set(value) {
this.selectedSemester = value;
}
},
verband: {
get() {
if (this.semester === false)
return false;
if (this.selectedVerband !== false) {
if (this.lvbListVerband.some(item => item.verband == this.selectedVerband))
return this.selectedVerband;
return false;
}
if (Array.isArray(this.students)) {
const first = this.students.find(Boolean);
if (this.lvbListVerband.every(item => item.verband != first.verband))
return false;
if (this.students.every(ps => ps.verband === first.verband))
return first.verband;
return false;
}
if (this.lvbListVerband.some(item => item.verband == this.students.verband))
return this.students.verband;
return false;
},
set(value) {
this.selectedVerband = value;
}
},
gruppe: {
get() {
if (this.verband === false)
return false;
if (this.selectedGruppe !== false) {
if (this.lvbListGruppe.some(item => item.gruppe == this.selectedGruppe))
return this.selectedGruppe;
return false;
}
if (Array.isArray(this.students)) {
const first = this.students.find(Boolean);
if (this.lvbListGruppe.every(item => item.gruppe != first.gruppe))
return false;
if (this.students.every(ps => ps.gruppe === first.gruppe))
return first.gruppe;
return false;
}
if (this.lvbListGruppe.some(item => item.gruppe == this.students.gruppe))
return this.students.gruppe;
return false;
},
set(value) {
this.selectedGruppe = value;
}
},
stgSemester() {
if (!this.lvbList.length)
return [];
const semester = new Set(this.lvbList.map(lvb => lvb.semester));
return Array.from(semester).sort((a, b) => a - b);
},
lvbListVerband() {
if (!this.lvbList.length)
return [];
if (this.semester === false)
return [];
return this.lvbList.filter(lvb => this.semester == lvb.semester);
},
semesterVerband() {
if (!this.lvbListVerband.length)
return [];
const verband = new Set(this.lvbListVerband.map(lvb => lvb.verband.replace(/ /g, '')));
return Array.from(verband).filter(Boolean).sort();
},
lvbListGruppe() {
if (!this.lvbListVerband.length)
return [];
if (this.verband === false)
return [];
return this.lvbListVerband.filter(lvb => this.verband == lvb.verband);
},
verbandGruppe() {
if (!this.lvbListGruppe.length)
return [];
const gruppe = new Set(this.lvbListGruppe.map(lvb => lvb.gruppe.replace(/ /g, '')));
return Array.from(gruppe).filter(Boolean).sort();
}
},
watch: {
studiengang_kz() {
this.loadGroupsForStg();
}
},
methods: {
loadGroupsForStg() {
this.lvbList = [];
if (this.studiengang_kz === false)
return;
this.$api
.call(ApiStvLvb.getTree(this.studiengang_kz))
.then(result => this.lvbList = result.data)
.catch(this.$fhcAlert.handleSystemError)
},
onSubmit() {
let params = {
studiengang_kz: this.studiengang_kz,
semester: this.semester,
verband: this.verband,
gruppe: this.gruppe
};
this.$emit("submit", params);
}
},
created() {
this.loadGroupsForStg();
},
template: /* html */`
<div class="stv-details-groups-lvb">
<fhc-form
v-if="allAreStudents && studiengang_kz"
ref="form"
class="input-group"
@submit.prevent="onSubmit"
>
<span class="input-group-text">
{{ $p.t('lehre/semester') }}
</span>
<select
v-model="semester"
class="form-select"
>
<option v-for="semester in stgSemester">{{ semester }}</option>
</select>
<span
class="input-group-text"
:class="{'text-muted': semester === false}"
>
{{ $p.t('lehre/verband') }}
</span>
<select
v-model="verband"
class="form-select"
:disabled="semester === false"
>
<option v-for="verband in semesterVerband">{{ verband }}</option>
</select>
<span
class="input-group-text"
:class="{'text-muted': verband === false}"
>
{{ $p.t('lehre/gruppe') }}
</span>
<select
v-model="gruppe"
class="form-select"
:disabled="verband === false"
>
<option v-for="gruppe in verbandGruppe">{{ gruppe }}</option>
</select>
<button
type="submit"
class="btn btn-primary"
:disabled="gruppe === false && verband === false"
>
{{ $p.t('ui/change') }}
</button>
</fhc-form>
<div v-if="!allAreStudents" class="alert alert-danger">
{{ $p.t('stv/groups_error_notallstudents') }}
</div>
<div v-if="!studiengang_kz" class="alert alert-danger">
{{ $p.t('stv/groups_error_notsamestg') }}
</div>
</div>`
};
@@ -0,0 +1,97 @@
import BsModal from "../../../../Bootstrap/Modal.js";
import FhcForm from "../../../../Form/Form.js";
import FormValidation from "../../../../Form/Validation.js";
import FormInput from "../../../../Form/Input.js";
import ApiStvGroups from '../../../../../api/factory/stv/group.js';
export default {
name: 'TabGroupsSpecial',
components: {
BsModal,
FhcForm,
FormValidation,
FormInput,
PvAutocomplete: primevue.autocomplete
},
props: {
defaultStg: Number
},
emits: [
"chosen"
],
data() {
return {
value: '',
groupSuggestions: []
};
},
methods: {
show() {
this.$refs.popup.show();
},
hide() {
this.$refs.popup.hide();
},
getGroupSuggestions({ query }) {
this.$api
.call(ApiStvGroups.search(query, this.defaultStg))
.then(result => this.groupSuggestions = result.data)
.catch(this.$fhcAlert.handleSystemError);
},
onSubmit(evt) {
if (!evt.defaultPrevented) {
evt.preventDefault();
this.hide();
}
},
onEnter() {
/**
* NOTE(chris): PrimeVue: AutoComplete: Enter does not submit form #5618
* @see https://github.com/primefaces/primevue/issues/5618
*
* this is fixed in 3.52.0
* until then this function fill fix it
*/
if (!this.$refs.autocomplete.$refs.input.overlayVisible) {
this.$refs.form.$el.requestSubmit();
}
},
modalOpened() {
this.$refs.autocomplete.$refs.input.$refs.focusInput.focus();
},
modalClosed() {
this.value = '';
this.$refs.form.clearValidation();
}
},
template: /* html */`
<bs-modal
ref="popup"
class="stv-details-groups-special"
@hide-bs-modal="modalClosed"
@shown-bs-modal="modalOpened"
>
<template #title>
{{ $p.t('gruppenmanagement/add_group') }}
</template>
<fhc-form ref="form" @submit="onSubmit">
<form-validation />
<div class="input-group">
<form-input
ref="autocomplete"
type="autocomplete"
name="gruppe_kurzbz"
v-model="value"
container-class="flex-grow-1"
input-class="w-100"
:suggestions="groupSuggestions"
:option-label="el => el.gruppe_kurzbz + (el.bezeichnung ? ' (' + el.bezeichnung + ')' : '')"
@complete="getGroupSuggestions"
@keydown.enter.capture="onEnter"
/>
<button type="submit" class="btn btn-primary">{{ $p.t('ui/hinzufuegen') }}</button>
</div>
</fhc-form>
</bs-modal>`
};
@@ -1,187 +0,0 @@
import {CoreFilterCmpt} from "../../../../filter/Filter.js";
import ApiStvGroups from '../../../../../api/factory/stv/group.js';
export default {
name: 'TblGroups',
components: {
CoreFilterCmpt,
},
inject: {
currentSemester: {
from: 'currentSemester',
},
},
props: {
student: Object
},
data() {
return {
tabulatorOptions: {
ajaxURL: 'dummy',
ajaxRequestFunc: () => this.$api.call(
ApiStvGroups.getGruppen(this.student.uid)
),
ajaxResponse: (url, params, response) => response.data,
initialFilter: {
logic: "and",
filters: [
{ field: "uid", operator: "eq", value: this.student.uid },
{
logic: "or",
filters: [
{ field: "studiensemester_kurzbz", operator: "eq", value: null },
{ field: "studiensemester_kurzbz", operator: "eq", value: this.currentSemester }
]
}
]
},
columns: [
{title: "Gruppe", field: "gruppe_kurzbz"},
{title: "Bezeichnung", field: "bezeichnung"},
{title: "Semester", field: "studiensemester_kurzbz"},
{
title: "automatisch generiert",
field: "generiert",
formatter: "tickCross",
hozAlign: "center",
formatterParams: {
tickElement: '<i class="fa fa-check text-success"></i>',
crossElement: '<i class="fa fa-xmark text-danger"></i>'
}
},
{title: "UID", field: "uid"},
{
title: 'Aktionen', field: 'actions',
minWidth: 150, // Ensures Action-buttons will be always fully displayed
formatter: (cell, formatterParams, onRendered) => {
const container = document.createElement('div');
container.className = "d-flex gap-2";
const data = cell.getData();
const button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-action';
button.innerHTML = '<i class="fa fa-xmark"></i>';
button.title = this.$p.t('ui', 'loeschen');
button.addEventListener('click', () =>
this.actionDeleteGroup(data.gruppe_kurzbz)
);
if (data.generiert)
button.disabled = true;
container.append(button);
return container;
},
frozen: true
},
],
layout: 'fitDataFill',
height: 'auto',
index: 'group_id',
persistenceID: 'stv-details-gruppe'
},
tabulatorEvents: [
{
event: 'tableBuilt',
handler: async () => {
await this.$p.loadCategory(['global', 'person', 'stv', 'ui', 'gruppenmanagement']);
let cm = this.$refs.table.tabulator.columnManager;
cm.getColumnByField('gruppe_kurzbz').component.updateDefinition({
title: this.$p.t('gruppenmanagement', 'gruppe')
});
cm.getColumnByField('bezeichnung').component.updateDefinition({
title: this.$p.t('ui', 'bezeichnung')
});
cm.getColumnByField('generiert').component.updateDefinition({
title: this.$p.t('gruppenmanagement', 'automatisch_generiert')
});
cm.getColumnByField('uid').component.updateDefinition({
title: this.$p.t('ui', 'student_uid')
});
//Interference with Filter if not commented out
/*
cm.getColumnByField('studiensemester_kurzbz').component.updateDefinition({
title: this.$p.t('lehre', 'studiensemester')
});*/
}
}
],
}
},
methods: {
actionDeleteGroup(gruppe_kurzbz) {
this.$fhcAlert
.confirmDelete()
.then(result => result
? gruppe_kurzbz
: Promise.reject({handled: true}))
.then(this.deleteGroup)
.catch(this.$fhcAlert.handleSystemError);
},
deleteGroup(gruppe_kurzbz) {
const group_id = {
id: this.student.uid,
gruppe_kurzbz: gruppe_kurzbz
};
return this.$api
.call(ApiStvGroups.deleteGroup(group_id))
.then(response => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete'));
}).catch(this.$fhcAlert.handleSystemError)
.finally(() => {
window.scrollTo(0, 0);
this.reload();
});
},
reload() {
this.$refs.table.reloadTable();
},
},
watch: {
currentSemester(newVal) {
if (newVal) {
this.$refs.table.tabulator.clearFilter(); // Clear old filters
this.$refs.table.tabulator.setFilter((data) => {
return (
data.uid === this.student.uid &&
(
data.studiensemester_kurzbz === newVal ||
data.studiensemester_kurzbz === null
)
);
});
}
},
student() {
this.$refs.table.reloadTable();
}
},
template: `
<div class="stv-details-gruppen h-100 pb-3">
<h5>{{$p.t('stv', 'tab_groups')}}</h5>
<core-filter-cmpt
ref="table"
:tabulator-options="tabulatorOptions"
:tabulator-events="tabulatorEvents"
table-only
:side-menu="false"
reload
:reload-btn-infotext="this.$p.t('table', 'reload')"
>
</core-filter-cmpt>
</div>
`
}
@@ -1,6 +1,7 @@
import {CoreFilterCmpt} from "../../filter/Filter.js";
import ListNew from './List/New.js';
import draggable from '../../../directives/draggable.js';
export default {
name: "ListPrestudents",
@@ -8,11 +9,18 @@ export default {
CoreFilterCmpt,
ListNew
},
directives: {
draggable
},
inject: {
'lists': {
lists: {
from: 'lists',
required: true
},
$reloadList: {
from: '$reloadList',
required: true
},
currentSemester: {
from: 'currentSemester',
required: true
@@ -51,8 +59,12 @@ export default {
{title:"Vornamen", field:"vornamen", visible:false, headerFilter: true},
{title:"TitelPost", field:"titelpost", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}},
{title:"Ersatzkennzeichen", field:"ersatzkennzeichen", headerFilter: true},
{title:"Geburtsdatum", field:"gebdatum", formatter:dateFormatter,
headerFilter: true, headerFilterFunc: function(headerValue, rowValue, rowData, filterParams) {
{
title: "Geburtsdatum",
field: "gebdatum",
formatter: dateFormatter,
headerFilter: true,
headerFilterFunc(headerValue, rowValue) {
const matches = headerValue.match(/^(([0-9]{2})\.)?([0-9]{2})\.([0-9]{4})?$/);
let comparestr = headerValue;
if(matches !== null) {
@@ -165,6 +177,39 @@ export default {
currentEndpointRawUrl: ''
}
},
computed: {
countsToHTML: function() {
return this.$p.t('global/ausgewaehlt')
+ ': <strong>' + (this.selectedcount || 0) + '</strong>'
+ ' | '
+ this.$p.t('global/gefiltert')
+ ': '
+ '<strong>' + (this.filteredcount || 0) + '</strong>'
+ ' | '
+ this.$p.t('global/gesamt')
+ ': <strong>' + (this.count || 0) + '</strong>';
},
selectedDragObject() {
return this.selected.map(item => {
let type, id;
if (item.uid) {
type = 'student';
id = item.uid;
} else if (item.prestudent_id) {
type = 'prestudent';
id = item.prestudent_id;
} else if (item.person_id) {
type = 'person';
id = item.person_id;
}
return {
...item,
type,
id
};
});
}
},
methods: {
reload() {
this.$refs.table.reloadTable();
@@ -172,10 +217,19 @@ export default {
actionNewPrestudent() {
this.$refs.new.open();
},
rowSelectionChanged(data) {
rowSelectionChanged(data, rows) {
this.selectedcount = data.length;
this.lastSelected = this.selected;
this.$emit('update:selected', data);
// set selected elements draggable
const tableEl = this.$refs.table?.$refs?.table;
if (tableEl) {
const oldDragables = tableEl.querySelectorAll('[draggable]');
for (const draggable of oldDragables)
draggable.removeAttribute('draggable');
}
rows.forEach(row => row.getElement().draggable = true);
},
autoSelectRows(data) {
if (this.lastSelected) {
@@ -234,14 +288,22 @@ export default {
} else
this.$refs.table.tabulator.setData(endpoint.url, params);
},
dragCleanup(evt) {
if (evt.dataTransfer.dropEffect == 'none')
return; // aborted or wrong target
this.$reloadList();
},
onKeydown(e) { // TODO(chris): this should be in the filter component
if (!this.focusObj)
return;
var next;
switch (e.code) {
case 'Enter':
case 'Space':
e.preventDefault();
const e2 = new Event('click', e);
var e2 = new Event('click', e);
e2.altKey = e.altKey;
e2.ctrlKey = e.ctrlKey;
e2.shiftKey = e.shiftKey;
@@ -250,13 +312,13 @@ export default {
break;
case 'ArrowUp':
e.preventDefault();
var next = this.focusObj.previousElementSibling;
next = this.focusObj.previousElementSibling;
if (next)
this.changeFocus(this.focusObj, next);
break;
case 'ArrowDown':
e.preventDefault();
var next = this.focusObj.nextElementSibling;
next = this.focusObj.nextElementSibling;
if (next)
this.changeFocus(this.focusObj, next);
break;
@@ -296,24 +358,19 @@ export default {
}
}
},
computed: {
countsToHTML: function() {
return this.$p.t('global/ausgewaehlt')
+ ': <strong>' + (this.selectedcount || 0) + '</strong>'
+ ' | '
+ this.$p.t('global/gefiltert')
+ ': '
+ '<strong>' + (this.filteredcount || 0) + '</strong>'
+ ' | '
+ this.$p.t('global/gesamt')
+ ': <strong>' + (this.count || 0) + '</strong>';
}
},
// TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component
// TODO(chris): filter component column chooser has no accessibilty features
template: `
<div class="stv-list h-100 pt-3">
<div class="tabulator-container d-flex flex-column h-100" :class="{'has-filter': filterKontoCount0 || filterKontoMissingCounter}" tabindex="0" @focusin="onFocus" @keydown="onKeydown">
<div
class="tabulator-container d-flex flex-column h-100"
:class="{'has-filter': filterKontoCount0 || filterKontoMissingCounter}"
tabindex="0"
@focusin="onFocus"
@keydown="onKeydown"
v-draggable:copyLink.capture="selectedDragObject"
@dragend="dragCleanup"
>
<core-filter-cmpt
ref="table"
:description="countsToHTML"
@@ -1,10 +1,28 @@
import ApiStvVerband from '../../../api/factory/stv/verband.js';
import drop from '../../../directives/drop.js';
import dragClick from '../../../directives/dragClick.js';
import ApiStvGroups from '../../../api/factory/stv/group.js';
import ApiStvDetails from '../../../api/factory/stv/details.js';
export default {
components: {
PvTreetable: primevue.treetable,
PvColumn: primevue.column
},
directives: {
drop,
dragClick
},
inject: {
$reloadList: {
from: '$reloadList',
required: true
},
currentSemester: {
from: 'currentSemester',
required: true
}
},
emits: [
'selectVerband'
],
@@ -225,6 +243,51 @@ export default {
this.selectedKey = {[currentNode.key]: true};
this.onSelectTreeNode(currentNode);
},
async toggleTreeNode(node) {
if (this.expandedKeys[node.key]) {
delete this.expandedKeys[node.key];
} else if (!node.leaf) {
await this.onExpandTreeNode(node);
this.expandedKeys[node.key] = true;
}
},
getStudentAjaxId(student) {
let res = student.id;
if (student.vorname && student.nachname)
res += ' (' + student.vorname + ' ' + student.nachname + ')';
return res;
},
dropStudents(node, students) {
const data = node.data;
let endpoint;
if (data.gruppe_kurzbz) {
endpoint = students.map(student => [
this.getStudentAjaxId(student),
ApiStvGroups.add(
student.id,
data.gruppe_kurzbz,
this.currentSemester
)
]);
} else {
const { semester, verband, gruppe } = data;
const params = { semester, verband, gruppe };
endpoint = students.map(student => [
this.getStudentAjaxId(student),
ApiStvDetails.saveStudent(
student.id,
this.currentSemester,
params
)
]);
}
return this.$api
.call(endpoint)
.then(this.$reloadList)
.catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
@@ -286,10 +349,21 @@ export default {
</template>
<template #body="{ node }">
<span
v-if="['semester', 'verband', 'gruppe', 'gruppe_kurzbz'].some(key => node.data.hasOwnProperty(key))"
:data-tree-item-key="node.key"
:title="node.data.studiengang_kz"
v-drag-click="() => toggleTreeNode(node)"
v-drop:link-strict.student-collection="(evt, students) => dropStudents(node, students)"
>
{{node.data.name}}
{{ node.data.name }}
</span>
<span
v-else
:data-tree-item-key="node.key"
:title="node.data.studiengang_kz"
v-drag-click="() => toggleTreeNode(node)"
>
{{ node.data.name }}
</span>
</template>
</pv-column>
+53
View File
@@ -0,0 +1,53 @@
import { bindDragEnterLeave } from '../helpers/DragAndDrop.js';
export default {
mounted(el, binding) {
const delay = parseInt(binding.arg) || 300;
let timeout = null;
function startCountdown() {
timeout = window.setTimeout(binding.value, delay);
}
function stopCountdown() {
if (timeout)
window.clearTimeout(timeout);
timeout = null;
}
let lastTarget;
let lastX;
let lastY;
function onDragOver(evt) {
if (lastX != evt.offsetX || lastY != evt.offsetY || lastTarget != evt.target) {
// moved
lastTarget = evt.target;
lastX = evt.offsetX;
lastY = evt.offsetY;
stopCountdown();
startCountdown();
}
}
function onEnter(evt) {
lastTarget = evt.target;
lastX = evt.offsetX;
lastY = evt.offsetY;
el.addEventListener('dragover', onDragOver);
startCountdown();
}
function onLeave() {
stopCountdown();
el.removeEventListener('dragover', onDragOver);
}
el.fhcDragClickCleanup = bindDragEnterLeave(el, onEnter, onLeave);
},
beforeUnmount(el) {
el.fhcDragClickCleanup();
delete el.fhcDragClickCleanup;
}
}
+101
View File
@@ -0,0 +1,101 @@
import { setTransferData, convertToValidDragObject } from '../helpers/DragAndDrop.js';
const EFFECTS = [
'none',
'copy',
'copyLink',
'copyMove',
'link',
'linkMove',
'move',
'all',
'uninitialized'
];
export default {
mounted(el, binding) {
updateValue(el, binding.value);
updateEffectAllowed(el, binding.arg);
// if modifier capture is set we assume it's on a parent element
// i.e: for dragging multiple elements
// otherwise set draggable attribute
if (!binding.modifiers.capture) {
el.draggable = true;
}
const bcc = new BroadcastChannel('fhc-dnd');
let blocked = false;
function onStart(evt) {
const value = el.dataset.fhcDraggableValue;
if (value) {
setTransferData(evt, JSON.parse(value), true);
if (el.dataset.fhcEffectAllowed)
evt.dataTransfer.effectAllowed = el.dataset.fhcEffectAllowed;
bcc.onmessage = e => {
if (e.data == 'block') {
blocked = true;
} else if (e.data == 'release') {
let evt = null;
if (blocked && blocked.evt) {
evt = blocked.evt;
}
blocked = false;
if (evt)
el.dispatchEvent(evt);
}
};
} else {
evt.preventDefault();
}
}
function onEnd(evt) {
if (blocked) {
blocked = {
evt,
dt: evt.dataTransfer
};
evt.stopPropagation();
el.dispatchEvent(new DragEvent("beforedragend", evt));
} else {
bcc.onmessage = () => {};
}
}
el.addEventListener('dragstart', onStart, binding.modifiers.capture);
el.addEventListener('dragend', onEnd, true);
el.fhcDraggableCleanup = () => {
el.removeEventListener('dragstart', onStart, binding.modifiers.capture);
el.removeEventListener('dragend', onEnd, true);
};
},
updated(el, binding) {
updateValue(el, binding.value);
updateEffectAllowed(el, binding.arg);
},
beforeUnmount(el) {
el.fhcDraggableCleanup();
delete el.fhcDraggableCleanup;
}
}
// Helper functions
function updateValue(el, value) {
value = convertToValidDragObject(value);
if (value) {
el.dataset.fhcDraggableValue = JSON.stringify(value);
} else if (el.dataset.fhcDraggableValue) {
delete el.dataset.fhcDraggableValue;
}
}
function updateEffectAllowed(el, effectAllowed) {
if (effectAllowed && EFFECTS.includes(effectAllowed)) {
el.dataset.fhcEffectAllowed = effectAllowed;
} else if (el.dataset.fhcEffectAllowed) {
delete el.dataset.fhcEffectAllowed;
}
}
+77
View File
@@ -0,0 +1,77 @@
import { getValidTransferData, eventHasTypes, bindDragEnterLeave } from '../helpers/DragAndDrop.js';
const EFFECTS = [
'move',
'copy',
'link',
'none'
];
export default {
mounted(el, binding) {
const allowedTypes = Object.keys(binding.modifiers);
allowedTypes.forEach(type => {
if (type.substr(-11) == '-collection') {
const singleType = type.substr(0, type.length-11);
if (!allowedTypes.includes(singleType))
allowedTypes.push(singleType);
}
});
const strict = binding.arg.match(/(strict-|-strict)/);
const arg = binding.arg.replace(/(strict-|-strict)/, '');
const effect = EFFECTS.includes(arg) ? arg : null;
const bcc = new BroadcastChannel('fhc-dnd');
let allowed = false;
function onEnter(evt) {
allowed = eventHasTypes(evt, allowedTypes, strict);
if (allowed) {
evt.preventDefault();
bcc.postMessage('block');
}
}
function onLeave(evt, wasDropped) {
if (allowed && !wasDropped) {
bcc.postMessage('release');
}
}
function onOver(evt) {
if (allowed) {
evt.preventDefault();
if (effect)
evt.dataTransfer.dropEffect = effect;
}
}
function onDrop(evt) {
let result = getValidTransferData(evt, allowedTypes, strict);
if (!Array.isArray(result) && !binding.modifiers[result.type] && allowedTypes.includes(result.type + '-collection'))
result = [result];
const res = binding.value(evt, result);
if (res instanceof Promise) {
res.then(r => {
bcc.postMessage('release');
return r;
});
} else {
bcc.postMessage('release');
}
}
const cleanupEnterLeave = bindDragEnterLeave(el, onEnter, onLeave);
el.addEventListener('dragover', onOver);
el.addEventListener('drop', onDrop);
el.fhcDropCleanup = () => {
cleanupEnterLeave();
el.removeEventListener('dragover', onOver);
el.removeEventListener('drop', onDrop);
};
},
beforeUnmount(el) {
el.fhcDropCleanup();
delete el.fhcDropCleanup;
}
}
+357 -56
View File
@@ -1,67 +1,368 @@
/**
* TODO(chris): This is only a prototype!!!
*/
const DragAndDrop = {
TYPE_LE: "lehreinheit",
TYPE_VEVENT: "vevent",
getValidTransferData(event, allowedTypes) {
const json = event.dataTransfer.getData('text');
let obj;
try {
obj = JSON.parse(json);
if (!obj.type)
return null;
if (allowedTypes && !allowedTypes.includes(obj.type))
return null;
} catch (error) {
return null;
}
return obj;
const TYPE_DEFINITION = {
lehreinheit: {
id: "lehreinheit_id",
dragIcon: "fa-solid fa-chalkboard-user",
extras: [
"stundenblockung"
]
},
isValidTransferData(event, allowedTypes) {
return this.getValidTransferData(event, allowedTypes) ? true : false;
vevent: {
id: "uid",
dragIcon: "fa-solid fa-calendar",
extras: [
"dtstart",
"dtend",
"summary"
]
},
getTransferData(event) {
const json = event.dataTransfer.getData('text');
return JSON.parse(json);
person: {
id: "person_id",
dragIcon: "fa-solid fa-user"
},
setTransferData(event, data) {
switch (data.type) {
case DragAndDrop.TYPE_LE:
data = DragAndDrop.fromLe(data);
break;
default:
if (data.dtstart && data.dtend && data.uid && data.summary) {
data = DragAndDrop.fromVEvent(data);
break;
}
return false; // No type found => abort
}
event.dataTransfer.setData('text', JSON.stringify(data));
return true;
student: {
id: "student_uid",
dragIcon: "fa-solid fa-user-graduate"
},
fromLe(data) {
const {
type = DragAndDrop.TYPE_LE,
lehreinheit_id: id,
stundenblockung
} = data;
return { type, id, stundenblockung };
},
fromVEvent(data) {
const {
type = DragAndDrop.TYPE_VEVENT,
uid: id,
dtstart,
dtend,
summary
} = data;
return { type, id, dtstart, dtend, summary };
prestudent: {
id: "prestudent_id",
dragIcon: "fa-solid fa-user-graduate text-muted"
}
// TODO: IMPLEMENT OTHER TYPES
};
export default DragAndDrop;
const VALID_TYPES = Object.keys(TYPE_DEFINITION);
const TYPE_CONSTANTS = Object.keys(TYPE_DEFINITION).reduce((res, type) => {
res['TYPE_' + type.toUpperCase()] = type;
return res;
}, {});
function isValidDragObject(value) {
if (!value)
return false;
if (Array.isArray(value))
return value.every(isValidDragObject);
if (!value.type)
return false;
if (value.type.substr(-11) == '-collection') {
if (!Object.prototype.hasOwnProperty.call(value, 'values'))
return false;
if (!VALID_TYPES.includes(value.type.substr(0, value.type.length-11)))
return false;
} else {
if (!Object.prototype.hasOwnProperty.call(value, 'id'))
return false;
if (!VALID_TYPES.includes(value.type))
return false;
if (TYPE_DEFINITION[value.type].extras) {
if (!TYPE_DEFINITION[value.type].extras.every(extra => Object.prototype.hasOwnProperty.call(value, extra)))
return false;
}
}
return true;
}
function getValidTransferData(event, allowedTypes, strict) {
let obj = null;
try {
obj = getTransferData(event, strict);
if (!obj)
return null;
if (!strict && Array.isArray(obj)) {
obj = obj.filter(isValidDragObject);
if (!obj.length)
return null;
} else if (!isValidDragObject(obj))
return null;
if (allowedTypes && allowedTypes.length) {
if (Array.isArray(obj)) {
if (strict && !obj.every(v => allowedTypes.includes(v.type))) {
return null;
} else if (!strict) {
obj = obj.filter(v => allowedTypes.includes(v.type));
if (!obj.length)
return null;
}
} else if (!allowedTypes.includes(obj.type)) {
return null;
}
}
} catch(_error) {
return null;
}
if (Array.isArray(obj) && obj.length == 1)
return obj.find(Boolean);
return obj;
}
function isValidTransferData(event, allowedTypes, strict) {
return getValidTransferData(event, allowedTypes, strict) ? true : false;
}
function getTransferData(event, strict) {
const result = [];
for (const type of event.dataTransfer.types) {
if (type.substr(0, 16) != 'application/fhc-') {
if (strict)
return null;
continue;
}
let base_type = type.substr(16);
let collection = false;
if (base_type.substr(-11) == '-collection') {
base_type = base_type.substr(0, base_type.length-11);
collection = true;
}
if (!VALID_TYPES.includes(base_type)) {
if (strict)
return null;
continue;
}
let data = JSON.parse(event.dataTransfer.getData(type));
if (collection)
result.push(...data.values);
else
result.push(data);
}
if (!result.length)
return null;
if (result.length == 1)
return result[0];
return result;
}
function convertToValidDragObject(data, strict) {
if (Array.isArray(data)) {
const converted = data.map(convertToValidDragObject).filter(Boolean);
if (!converted.length)
return undefined;
if (strict && converted.length != data.length)
return undefined;
const sorted = converted.reduce((res, item) => {
if (!res[item.type])
res[item.type] = [];
res[item.type].push(item);
return res;
}, {});
return Object.entries(sorted).map(([type, values]) => {
if (values.length > 1) {
return {
type: type + '-collection',
values
};
}
return values[0];
});
}
if (Object.prototype.hasOwnProperty.call(data, 'type') && isValidDragObject(data)) {
return data;
}
const found = Object.entries(TYPE_DEFINITION).find(([ , typedef ]) => {
if (!Object.prototype.hasOwnProperty.call(data, typedef.id))
return false;
if (typedef.extras) {
if (!typedef.extras.every(extra => Object.prototype.hasOwnProperty.call(data, extra)))
return false;
}
return true;
});
if (!found) {
return undefined;
}
const [ type, typedef ] = found;
const newData = {};
newData.type = type;
newData.id = data[typedef.id];
if (typedef.extras)
typedef.extras.forEach(extra => newData[extra] = data[extra]);
return newData;
}
function setTransferData(event, validDragObject, setDragImage = false) {
if (setDragImage) {
const dragItems = Array.isArray(validDragObject) ? validDragObject : [ validDragObject ];
const dragElements = dragItems.map(item => {
const icon = document.createElement('i');
const label = document.createElement('span');
const iconContainer = document.createElement('span');
iconContainer.className = 'btn btn-outline-dark bg-light';
label.className = 'small';
if (TYPE_DEFINITION[item.type]) {
icon.className = TYPE_DEFINITION[item.type].dragIcon || 'fa-solid fa-question';
label.textContent = item.id;
} else if (item.type.substr(-11) == '-collection' && TYPE_DEFINITION[item.type.substr(0, item.type.length-11)]) {
iconContainer.style.boxShadow = '3px 3px var(--bs-btn-border-color)';
icon.className = TYPE_DEFINITION[item.type.substr(0, item.type.length-11)].dragIcon || 'fa-solid fa-question';
label.textContent = 'x' + item.values.length;
} else {
icon.className = 'fa-solid fa-question';
label.textContent = item.id || '';
}
iconContainer.append(icon);
const itemContainer = document.createElement('div');
itemContainer.className = 'd-flex flex-column align-items-center gap-2 small';
itemContainer.append(iconContainer, label);
return itemContainer;
});
const container = document.createElement('div');
container.className = 'd-flex flex-row gap-2 small';
container.append(...dragElements);
document.body.append(container);
event.dataTransfer.setDragImage(container, -25, 0);
requestAnimationFrame(() => {
document.body.removeChild(container);
});
}
if (Array.isArray(validDragObject)) {
return validDragObject.forEach(data => setTransferData(event, data));
}
event.dataTransfer.setData('application/fhc-' + validDragObject.type, JSON.stringify(validDragObject));
}
/**
* check if the dataTransfer types are in the allowed types array
* if strict is disabled at least one type must be the allowed array
* otherwise all types have to be in the allowed array
*
* @param Event event
* @param Array allowedTypes
* @param Boolean strict
*/
function eventHasTypes(event, allowedTypes, strict) {
if (!allowedTypes || !allowedTypes.length)
allowedTypes = VALID_TYPES;
allowedTypes = allowedTypes.map(type => 'application/fhc-' + type);
const dataTypes = [...event.dataTransfer.types];
// NOTE(chris): if dragging across browsers the dataTransfer object is
// set to a default one without data. Since we do not support dragging
// across browsers (yet) we return false which will disallow dropping.
if (!dataTypes.length)
return false;
if (!strict)
return allowedTypes.some(type => [...event.dataTransfer.types].includes(type));
return [...event.dataTransfer.types].every(type => allowedTypes.includes(type));
}
function bindDragEnterLeave(el, onEnter, onLeave) {
// NOTE(chris): add save dragenter and dragleave events
// that won't fire when hovering over child elements
let skipLeave = false;
let skipLeaveParent = true;
function init(evt) {
skipLeave = false;
skipLeaveParent = true;
// add global listeners
window.addEventListener('dragenter', globalDragenter, true);
window.addEventListener('dragleave', globalDragleave, true);
window.addEventListener('drop', globalDrop, true);
// call enter
onEnter(evt);
// remove self
el.removeEventListener('dragenter', init);
}
function cleanup(evt, wasDropped) {
// remove global listeners
window.removeEventListener('dragenter', globalDragenter, true);
window.removeEventListener('dragleave', globalDragleave, true);
window.removeEventListener('drop', globalDrop, true);
// call leave
onLeave(evt, wasDropped);
// add init
el.addEventListener('dragenter', init);
}
function globalDragenter(evt) {
skipLeaveParent = false;
if (el != evt.target && !el.contains(evt.target)) {
cleanup(evt);
} else {
skipLeave = true;
}
}
function globalDragleave(evt) {
if (el != evt.target && !el.contains(evt.target)) {
if (skipLeaveParent) {
skipLeaveParent = false;
return;
}
} else {
if (skipLeave) {
skipLeave = false;
return;
}
}
cleanup(evt);
}
function globalDrop(evt) {
cleanup(evt, true);
}
el.addEventListener('dragenter', init);
return () => {
// cleanup
el.removeEventListener('dragenter', init);
}
}
export {
isValidDragObject,
getValidTransferData,
isValidTransferData,
getTransferData,
convertToValidDragObject,
setTransferData,
eventHasTypes,
bindDragEnterLeave
};
export default {
...TYPE_CONSTANTS,
isValidDragObject,
getValidTransferData,
isValidTransferData,
getTransferData,
convertToValidDragObject,
setTransferData,
eventHasTypes,
bindDragEnterLeave
};
+3
View File
@@ -546,6 +546,9 @@ export default {
} else {
console.error("FhcApi: method not allowed:", method);
}
},
getErrorHandler(config) {
return get_error_handler(config);
}
};
+161
View File
@@ -1535,6 +1535,26 @@ $phrases = array(
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'change',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Ändern',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Change',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',
@@ -25431,6 +25451,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'error_fieldNotFound',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{field} nicht gefunden',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{field} not found',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'notiz',
@@ -39324,6 +39364,47 @@ array(
)
)
),
// groups
array(
'app' => 'core',
'category' => 'stv',
'phrase' => 'groups_error_notallstudents',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler: Es können nur Studierende mit UID verschoben werden.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error: Only students with UID can be moved.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'stv',
'phrase' => 'groups_error_notsamestg',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler: Alle Studierenden müssen im selben Studiengang sein.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error: All students must be in the same program.',
'description' => '',
'insertvon' => 'system'
)
)
),
//**************************** CORE/document_export
array(
@@ -43299,6 +43380,66 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
'phrase' => 'special_groups',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Spezialgruppen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Special groups',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
'phrase' => 'add_group',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Gruppe hinzufügen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Add group',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
'phrase' => 'groups_added',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{n} Gruppen hinzufügen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Added {n} groups',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
@@ -43339,6 +43480,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
'phrase' => 'error_alreadyInGroup',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Student {uid} ist bereits im {studiensemester_kurzbz} dieser Gruppe zugeteilt. Entfernen Sie vorher diese Zuteilung.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Student {uid} is already assigned to this group in {studiensemester_kurzbz}. Remove this assignment beforehand.',
'description' => '',
'insertvon' => 'system'
)
)
),
//////////// FHC4 Phrases Gruppen End ////////////
array(