Merge branch 'feature-68770/FHC4_Studierendenverwaltung_Funktion_Personen_Zusammenlegen' into studvw_2025-11_rc2

This commit is contained in:
Harald Bamberger
2025-11-20 13:28:37 +01:00
11 changed files with 467 additions and 28 deletions
+6 -1
View File
@@ -61,7 +61,11 @@ $config['tabs'] =
'notes' => [
//if true, the count of Messages will be shown in the header of the Tab Messages
'showCountNotes' => true
]
],
'combinePeople' => [
//multitab should only be shown with this length of selection
'validCountMulti' => 2,
],
];
// List of fields to show when ZGV_DOKTOR_ANZEIGEN is defined
@@ -117,5 +121,6 @@ $config['students_tab_order'] = [
'status',
'groups',
'finalexam',
'combinePeople',
'archive',
];
@@ -406,6 +406,11 @@ class Config extends FHCAPI_Controller
'showEdit' => $this->permissionlib->isBerechtigt('admin')
]
];
$result['combinePeople'] = [
'title' => $this->p->t('stv', 'tab_combine_people'),
'component' => './Stv/Studentenverwaltung/Details/CombinePeople.js',
'config' => $config['combinePeople']
];
$result['kontaktieren'] = [
'title' => $this->p->t('stv', 'tab_kontaktieren'),
@@ -765,6 +765,86 @@ class Students extends FHCAPI_Controller
$this->terminateWithSuccess($data);
}
/**
* @param string $studiensemester_kurzbz
*
* @return void
*/
public function search($studiensemester_kurzbz)
{
$this->addMeta('ci_method', __FUNCTION__);
$this->addMeta('ci_params', array(
'studiensemester_kurzbz' => $studiensemester_kurzbz
));
$this->load->library('SearchLib', [ 'config' => 'searchstv' ]);
$this->load->library('form_validation');
$this->form_validation->set_rules('searchstr', 'searchstr', 'required');
$this->form_validation->set_rules('types[]', 'types', 'required');
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->searchlib->search($this->input->post('searchstr'), $this->input->post('types'));
$data = $this->getDataOrTerminateWithError($result);
$this->load->model('crm/Prestudent_model', 'PrestudentModel');
$this->prepareQuery($studiensemester_kurzbz);
$this->PrestudentModel->addSelect("COALESCE(v.semester::text, CASE WHEN public.get_rolle_prestudent(tbl_prestudent.prestudent_id, NULL) IN ('Aufgenommener', 'Bewerber', 'Wartender', 'interessent') THEN public.get_absem_prestudent(tbl_prestudent.prestudent_id, NULL)::text ELSE ''::text END) AS semester", false);
$this->PrestudentModel->addSelect('v.verband');
$this->PrestudentModel->addSelect('v.gruppe');
//add status per semester
$this->PrestudentModel->addSelect(
"(
SELECT status_kurzbz
FROM public.tbl_prestudentstatus pss
WHERE pss.prestudent_id = public.tbl_prestudent.prestudent_id
AND pss.studiensemester_kurzbz = " . $this->PrestudentModel->escape($studiensemester_kurzbz) . "
ORDER BY GREATEST(pss.datum, '0001-01-01') DESC
LIMIT 1
) AS statusofsemester"
);
$this->addSelectPrioRel();
$this->addFilter($studiensemester_kurzbz);
$prestudent_ids = [];
$student_uids = [];
$this->addMeta('data', $data);
foreach ($data as $row) {
$dataset = json_decode($row->data);
if ($row->type == 'prestudent') {
$prestudent_ids[] = $dataset->prestudent_id;
} elseif ($row->type == 'student') {
$student_uids[] = $dataset->uid;
}
}
if ($prestudent_ids && $student_uids) {
$this->PrestudentModel->db->where_in('tbl_prestudent.prestudent_id', $prestudent_ids);
$this->PrestudentModel->db->or_where_in('s.student_uid', $student_uids);
} elseif ($prestudent_ids) {
$this->PrestudentModel->db->where_in('tbl_prestudent.prestudent_id', $prestudent_ids);
} elseif ($student_uids) {
$this->PrestudentModel->db->where_in('s.student_uid', $student_uids);
} else {
$this->terminateWithSuccess([]);
}
$result = $this->PrestudentModel->load();
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
/**
* @param string|null $studiensemester_kurzbz
* @param string $type
+8
View File
@@ -46,6 +46,14 @@ export default {
url: url
};
},
search(params, studiensemester_kurzbz) {
return {
method: 'post',
url: 'api/frontend/v1/stv/students/search/'
+ encodeURIComponent(studiensemester_kurzbz),
params
};
},
verband(relative_path) {
return {
method: 'get',
+38
View File
@@ -148,6 +148,44 @@ const router = VueRouter.createRouter({
next();
}
},
{
name: 'search',
path: `/${ciPath}/studentenverwaltung/:studiensemester_kurzbz/search/:searchstr`,
component: FhcStudentenverwaltung,
props(route) {
return {
url_studiensemester_kurzbz: route.params.studiensemester_kurzbz,
url_mode: 'search',
url_prestudent_id: route.params.searchstr
};
},
beforeEnter(to, from, next) {
const isSemester = /^[WS]S\d{4}$/.test(to.params.studiensemester_kurzbz);
if (!isSemester) {
return next({name: 'index'});
}
next();
}
},
{
name: 'search_w_types',
path: `/${ciPath}/studentenverwaltung/:studiensemester_kurzbz/search/:types/:searchstr`,
component: FhcStudentenverwaltung,
props(route) {
return {
url_studiensemester_kurzbz: route.params.studiensemester_kurzbz,
url_mode: 'search',
url_prestudent_id: route.params.type + '/' + route.params.searchstr
};
},
beforeEnter(to, from, next) {
const isSemester = /^[WS]S\d{4}$/.test(to.params.studiensemester_kurzbz);
if (!isSemester) {
return next({name: 'index'});
}
next();
}
},
{
path: '/:pathMatch(.*)*',
redirect: {
@@ -146,6 +146,9 @@ export default {
},
'url_mode': function () {
this.handlePersonUrl();
},
url_prestudent_id() {
this.handlePersonUrl();
}
},
methods: {
@@ -159,7 +162,7 @@ export default {
}
},
buildPrestudentSearchResultLink(data) {
return this.$fhcApi.getUri(
return this.$api.getUri(
'/studentenverwaltung'
+ '/' + this.studiensemesterKurzbz
+ '/prestudent/'
@@ -167,7 +170,7 @@ export default {
);
},
buildStudentSearchResultLink(data) {
return this.$fhcApi.getUri(
return this.$api.getUri(
'/studentenverwaltung'
+ '/' + this.studiensemesterKurzbz
+ '/student/'
@@ -175,7 +178,7 @@ export default {
);
},
buildPersonSearchResultLink(data) {
return this.$fhcApi.getUri(
return this.$api.getUri(
'/studentenverwaltung'
+ '/' + this.studiensemesterKurzbz
+ '/person/'
@@ -249,6 +252,21 @@ export default {
ApiStv.students.person(this.$route.params.person_id, 'CURRENT_SEMESTER'),
true
);
} else if (this.$route.params.searchstr) {
const searchsettings = {
searchstr: this.$route.params.searchstr,
types: this.$route.params.types?.split('+') || []
};
// init into student list
this.$refs.stvList.updateUrl(
ApiStv.students.search(searchsettings, this.studiensemesterKurzbz)
);
// init into searchbar
this.$refs.searchbar.searchsettings.searchstr = searchsettings.searchstr;
this.$refs.searchbar.searchsettings.types = searchsettings.types;
this.$nextTick(this.blurSearchbar);
}
},
checkUrlStudiengang() {
@@ -269,6 +287,36 @@ export default {
});
}
}
},
onSearch(e) {
const searchsettings = { ...this.$refs.searchbar.searchsettings };
if (searchsettings.searchstr.length >= 2) {
this.blurSearchbar();
if (!searchsettings.types.length || searchsettings.types.length == this.$refs.searchbar.types.length) {
this.$router.push({
name: 'search',
params: {
studiensemester_kurzbz: this.studiensemesterKurzbz,
searchstr: searchsettings.searchstr
}
});
} else {
this.$router.push({
name: 'search_w_types',
params: {
studiensemester_kurzbz: this.studiensemesterKurzbz,
searchstr: searchsettings.searchstr,
types: searchsettings.types.join('+')
}
});
}
}
},
blurSearchbar() {
this.$refs.searchbar.$refs.input.blur();
this.$refs.searchbar.abort();
this.$refs.searchbar.hideresult();
}
},
created() {
@@ -376,9 +424,12 @@ export default {
<span class="fa-solid fa-table-list"></span>
</button>
<core-searchbar
ref="searchbar"
:searchoptions="searchbaroptions"
:searchfunction="searchfunction"
class="searchbar position-relative w-100"
show-btn-submit
@submit.prevent="onSearch"
></core-searchbar>
</header>
<div class="container-fluid overflow-hidden">
@@ -0,0 +1,83 @@
export default {
name: "TabCombinePeople",
inject: {
cisRoot: {
from: 'cisRoot'
},
},
props: {
modelValue: Object,
},
data(){
return {
iframeUrl: null,
viewLoaded: false
}
},
computed: {
personIds() {
return Array.isArray(this.modelValue)
? this.modelValue.map(e => e.person_id)
: [this.modelValue.person_id];
},
detailStringPerson1(){
let person1 = this.modelValue[0];
return person1.vorname + " " + person1.nachname + "(" + person1.person_id + ")";
},
detailStringPerson2(){
let person2 = this.modelValue[1];
return person2.vorname + " " + person2.nachname + "(" + person2.person_id+ ")";
},
},
methods: {
combinePeople(){
this.viewLoaded = true;
let person1_id = this.personIds[0];
let person2_id = this.personIds[1];
if(person1_id == person2_id) {
return this.$fhcAlert.alertError(this.$p.t('stv', 'error_combinePeople_samePerson'));
}
let linkCombinePeople = this.cisRoot + 'vilesci/stammdaten/personen_wartung.php?person_id_1=' + person1_id + '&person_id_2='+ person2_id;
this.openLink(linkCombinePeople);
},
openLink(url) {
this.iframeUrl = url;
},
goBack(){
this.viewLoaded = false;
this.iframeUrl = null;
}
},
template: /*html*/ `
<div class="stv-details-combine-people h-100 pb-3">
<div v-if="!this.viewLoaded">
<h4>Personen zusammenlegen</h4>
<div v-if="this.modelValue.length">
<div v-if="this.modelValue.length == 2">
<p>{{$p.t('stv', 'question_combine_people', { person1: detailStringPerson1, person2: detailStringPerson2 })}}</p>
<button class="btn btn-primary" @click="combinePeople">{{$p.t('ui', 'ok')}}</button>
</div>
<div v-else>
ungültige Anzahl: {{this.modelValue.length}} <!-- should not be seen anymore-->
</div>
</div>
</div>
<div v-else>
<button class="btn btn-secondary" @click="goBack">{{$p.t('ui', 'cancel')}}</button>
</div>
<!-- Iframe-Section -->
<iframe
v-if="iframeUrl"
:src="iframeUrl"
class="w-100 mt-4 border-0"
style="height: 600px;"
></iframe>
</div>
`
};
@@ -133,7 +133,12 @@ export default {
{
return Promise.resolve({ data: []});
}
return this.$api.call({method: 'post', url, params});
/**
* NOTE(chris): Because of a bug in Tabulator
* we need to get the params from elsewhere.
* @see https://github.com/olifolkerd/tabulator/issues/4318
*/
return this.$api.call({...config, url, params: this.tabulatorOptions.ajaxParams});
},
ajaxResponse: (url, params, response) => {
return response?.data;
@@ -294,16 +299,17 @@ export default {
if (this.filter.length)
params.filter = this.filter;
this.tabulatorOptions.ajaxURL = endpoint.url;
this.tabulatorOptions.ajaxParams = { ...params };
this.tabulatorOptions.ajaxConfig = method;
if (!this.$refs.table.tableBuilt) {
if (!this.$refs.table.tabulator) {
this.tabulatorOptions.ajaxURL = endpoint.url;
this.tabulatorOptions.ajaxParams = params;
} else
if (this.$refs.table.tabulator) {
this.$refs.table.tabulator.on("tableBuilt", () => {
this.$refs.table.tabulator.setData(endpoint.url, params);
this.$refs.table.tabulator.setData(endpoint.url, params, method);
});
}
} else
this.$refs.table.tabulator.setData(endpoint.url, params);
this.$refs.table.tabulator.setData(endpoint.url, params, method);
},
dragCleanup(evt) {
if (evt.dataTransfer.dropEffect == 'none')
+59 -3
View File
@@ -33,7 +33,8 @@ export default {
data() {
return {
current: null,
tabs: {}
tabs: {},
count: null
}
},
computed: {
@@ -113,10 +114,12 @@ export default {
};
}
if (Array.isArray(config))
if (Array.isArray(config)) {
config.forEach((item, key) => _addToTabs(key, item));
else
}
else {
Object.entries(config).forEach(([key, item]) => _addToTabs(key, item));
}
if (this.current === null || !tabs[this.current]) {
if (tabs[this.default])
@@ -129,6 +132,57 @@ export default {
updateSuffix() {
this.getTabSuffix(this.currentTab);
},
removeInvalidCountTabs(){
if(this.modelValue.length)
{
let countIst = this.modelValue.length;
const tabsToDelete = [];
Object.entries(this.config).forEach(([key, item]) => {
const target = item?.config ? item : item?.value || item;
// check config for validCountMulti
if (target.config?.validCountMulti !== undefined) {
let tab;
let countSoll;
tab = key;
countSoll = target.config.validCountMulti;
//check if tab is existing
if (countSoll !== undefined && countSoll == countIst) {
//add tab if it was removed before
if (tab in this.tabs == false) {
const value = Vue.reactive({
suffix: '',
showSuffix: item.showSuffix || false
});
this.tabs[tab] = {
component: Vue.markRaw(Vue.defineAsyncComponent(() => import(item.component))),
title: Vue.computed(() => item.title || tab),
config: item.config,
tab,
value,
suffixhelper: item.suffixhelper ?? null
};
}
}
//add to toDeleteArray if count is not allowed
if (countSoll !== undefined && countSoll !== countIst) {
tabsToDelete.push(tab);
}
}
});
// Delete all tabs with count not allowed
tabsToDelete.forEach(k => {
delete this.tabs[k];
});
}
},
async getTabSuffix(tab) {
if (!tab.value.showSuffix) {
return;
@@ -151,9 +205,11 @@ export default {
},
mounted() {
this.getTabSuffixes();
this.removeInvalidCountTabs();
},
updated() {
this.getTabSuffixes();
this.removeInvalidCountTabs();
},
template: `
<template v-if="useprimevue">
+39 -14
View File
@@ -23,7 +23,17 @@ export default {
mergedStudent,
mergedPerson
},
props: [ "searchoptions", "searchfunction" ],
props: {
searchoptions: {
type: Object,
required: true
},
searchfunction: {
type: Function,
required: true
},
showBtnSubmit: Boolean
},
provide() {
return {
query: Vue.computed(() => this.lastQuery)
@@ -102,11 +112,22 @@ export default {
>
<button
v-if="searchsettings.searchstr"
type="button"
class="searchbar_input_clear btn btn-outline-secondary"
@click="clearInput"
@focusin.stop
>
<i class="fas fa-close"></i>
</button>
<button
v-if="showBtnSubmit"
type="submit"
class="btn btn-primary"
:title="$p.t('search/submit')"
:aria-label="$p.t('search/submit')"
>
<i class="fas fa-search"></i>
</button>
<button
data-bs-toggle="collapse"
data-bs-target="#searchSettings"
@@ -219,12 +240,12 @@ export default {
});
}
},
methods: {
clearInput() {
this.searchsettings.searchstr = "";
this.hideresult();
this.$refs.input.focus()
},
methods: {
clearInput() {
this.searchsettings.searchstr = "";
this.hideresult();
this.$refs.input.focus();
},
getInitiallySelectedTypes() {
let result = false;
if (this.searchoptions.origin) {
@@ -283,13 +304,7 @@ export default {
this.calcSearchResultHeight();
},
search: function() {
if( this.searchtimer !== null ) {
clearTimeout(this.searchtimer);
}
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.abort();
if( this.searchsettings.searchstr.length >= 2 ) {
this.calcSearchResultExtent();
this.searchtimer = setTimeout(
@@ -300,6 +315,16 @@ export default {
this.showresult = false;
}
},
abort() {
if (this.searchtimer !== null) {
clearTimeout(this.searchtimer);
}
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.searchresult = [];
},
callsearchapi: function() {
this.error = null;
this.searchresult.splice(0, this.searchresult.length);
+82
View File
@@ -49211,6 +49211,26 @@ and represent the current state of research on the topic. The prescribed citatio
// FHC-4 Finetuning END
//**************************** CORE/search
array(
'app' => 'core',
'category' => 'search',
'phrase' => 'submit',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'suchen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'search',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'search',
@@ -51668,6 +51688,68 @@ I have been informed that I am under no obligation to consent to the transmissio
)
),
// ### DOKUMENTE ERSTELLEN PHRASEN END ###
// ### Personen zusammenlegen Phrasen BEGIN
array(
'app' => 'core',
'category' => 'stv',
'phrase' => 'tab_combine_people',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Personen zusammenlegen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Combine People',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'stv',
'phrase' => 'question_combine_people',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Personen {person1} und {person2} zusammenlegen?',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Merge the persons {person1} and {person2}?',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'stv',
'phrase' => 'error_combinePeople_samePerson',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Keine Zusammenlegung möglich bei identischer Person ID!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'No merging possible with identical person ID"',
'description' => '',
'insertvon' => 'system'
)
)
),
// ### Personen zusammenlegen Phrasen END
);