Compare commits

...
Author SHA1 Message Date
chfhtw aa026515a5 Merge branch 'master' into feature-77552/CIS4_MA_ReportsWidget 2026-09-30 16:25:48 +02:00
Harald Bamberger 3d8c4fa4d0 Merge branch 'feature-77757/WidgetsBerechtigung' 2026-09-30 15:48:00 +02:00
Harald Bamberger 272014bd78 remove odd phrases 2026-09-30 15:38:43 +02:00
Harald Bamberger 76a8e00edf Merge branch 'master' into feature-77757/WidgetsBerechtigung 2026-09-30 15:23:08 +02:00
chfhtw 65bf53223c Allow params in Report/Graph title 2026-09-30 14:01:55 +02:00
chfhtw 1c1e578ee4 Report/Graph: make title editable 2026-09-30 14:01:24 +02:00
chfhtw a300a28047 Use label instead of value for Aggregator name params if available 2026-09-30 14:01:12 +02:00
chfhtw cb09becd09 Better phrase 2026-09-30 13:30:17 +02:00
chfhtw 7b3bc68f54 Allow params in Report/KPI Aggregator names 2026-09-30 13:28:26 +02:00
Harald Bamberger ce62713790 Merge branch 'bug-82297/ausweis-date-preview-issue' 2026-09-30 12:11:13 +02:00
chfhtw 80584b64cb Better error display 2026-09-30 11:25:28 +02:00
chfhtw 0e27b26e80 Report/KPI hideFooter to false 2026-09-30 11:25:11 +02:00
chfhtw 794c3b7a5b Bugfix: wrong key value order 2026-09-30 11:24:57 +02:00
Harald Bamberger 34761db664 rename phrase 2026-09-30 10:56:30 +02:00
Harald Bamberger c7131ed775 revert changes to old cis since issue does not occur there 2026-09-30 10:53:01 +02:00
chfhtw 76cd1dda94 Phrasen 2026-09-30 10:48:15 +02:00
chfhtw f5b80cee11 Merge branch 'master' into feature-77552/CIS4_MA_ReportsWidget 2026-09-30 10:40:10 +02:00
chfhtw 523676990b Report/Graph widget 2026-09-30 10:39:27 +02:00
chfhtw a3e02f05f1 Colorpicker CSS in input group 2026-09-30 10:33:01 +02:00
chfhtw 7d1fa17ab4 Allow charts in CIS 2026-09-30 10:32:26 +02:00
chfhtw cfa17943c0 FhcChart: add reflow method and CSS height for intermediate objects 2026-09-30 10:32:02 +02:00
chfhtw c37ef64ba0 Highcharts plugin fix: default empty options 2026-09-30 10:30:18 +02:00
Harald Bamberger b57e1984a8 Merge branch 'master' into bug-82297/ausweis-date-preview-issue 2026-09-30 10:20:28 +02:00
chfhtw 917721ed3c rename class 2026-09-30 09:57:52 +02:00
chfhtw 0ccd6b8075 remove debug logging 2026-09-30 09:56:47 +02:00
chfhtw febe0d8a65 Fix: wrong case in variable 2026-09-30 09:56:29 +02:00
chfhtw 8bd5383f79 Bugfix: new Widget differences FE/BE 2026-09-28 16:35:26 +02:00
chfhtw 4983bcd557 Code Quality 2026-09-28 16:02:47 +02:00
chfhtw c3c0518a39 Fix: missing this 2026-09-28 16:02:33 +02:00
chfhtw e2d26a063c Remove deprecated code 2026-09-28 16:02:18 +02:00
chfhtw 8d64c2caaa Hide loader on empty input & disable size for KPI widget 2026-09-28 16:01:50 +02:00
chfhtw b0996a2d57 Remove unnecessary import 2026-09-28 09:08:12 +02:00
chfhtw bbd7d11588 Tabulator Bug: double selection 2026-09-25 10:00:41 +02:00
chfhtw e09dd940bc add KPI icon 2026-09-17 09:08:16 +02:00
chfhtw f33e244c04 Replace mutator with formatter 2026-09-17 09:08:05 +02:00
chfhtw c95ff659b8 saveguard 2026-09-15 15:09:29 +02:00
chfhtw e58b13f776 Add edit permission component 2026-09-15 13:11:48 +02:00
chfhtw 2959a23765 Styling optimations 2026-09-15 11:37:08 +02:00
chfhtw 62475f7698 Fix selection 2026-09-15 10:58:25 +02:00
chfhtw 894ae01a34 Fix: use emits 2026-09-14 16:08:41 +02:00
chfhtw ae9fa8aac1 Fix: unselect previously selected rows on programmatically selection 2026-09-14 16:08:15 +02:00
chfhtw 8e259455eb Initiate arguments 2026-09-14 15:40:11 +02:00
chfhtw a28cdcee40 Handle Generators from extensions 2026-09-14 15:18:47 +02:00
chfhtw 08cabe6e96 Remove widget in WidgetAdmin app 2026-09-14 15:18:11 +02:00
chfhtw 892fe4e6ea Make aggregators removable 2026-09-14 14:01:31 +02:00
chfhtw 33f4019731 Styling 2026-09-14 13:43:19 +02:00
chfhtw 2a3af7d924 Aggregators default item 2026-09-14 13:39:41 +02:00
chfhtw 17e74f929b Remove TODO: leave index as current aggregator indicator 2026-09-14 12:55:39 +02:00
chfhtw e64c998b7a Phrases & cleanup 2026-09-14 12:52:15 +02:00
chfhtw 8c82c046dc Widget.php phrasen 2 2026-09-14 09:37:27 +02:00
chfhtw f5824c272f dashboard/Admin.php remove typo 2026-09-14 09:33:51 +02:00
chfhtw 88715e9dd2 StudiengangEP phrasen 2026-09-14 09:31:13 +02:00
chfhtw b7c0a9aa49 Comment for better understanding 2026-09-14 09:25:21 +02:00
chfhtw 41c59aa195 Widget.php phrasen 2026-09-14 09:18:11 +02:00
chfhtw 63d4a81e80 Report.php permissions 2026-09-14 09:00:32 +02:00
chfhtw 3826ad68f4 Values validation 2026-09-14 09:00:11 +02:00
chfhtw ad0145b915 cleanup Report.php 2026-09-14 08:59:22 +02:00
chfhtw 6324bc50b9 Add CalculatedVars "Studiengang of user" 2026-09-11 11:32:27 +02:00
chfhtw 3871326880 Expose $p, $api and $fhcAlert as bound member to calculatedVars composables 2026-09-11 11:31:54 +02:00
chfhtw ea3d04d9ae Handle calculated values if they need user interaction 2026-09-11 11:29:58 +02:00
chfhtw e50bc030c9 Fix: v-model chain 2026-09-11 11:26:49 +02:00
chfhtw 2a138f774a Studiengang allowed endpoint 2026-09-11 11:23:47 +02:00
chfhtw bade3753f9 Fix: saveguard for choosing defailt aggregator 2026-09-11 11:23:15 +02:00
chfhtw 054cddfdce Fix: use JSON for comparison 2026-09-11 11:22:31 +02:00
chfhtw 2a34cb32c8 Merge branch 'master' into feature-77552/CIS4_MA_ReportsWidget 2026-09-10 09:01:45 +02:00
chfhtw 57d8c21f63 Basic KPI widget 2026-09-10 09:01:16 +02:00
chfhtw 28b1269324 Studienjahr getAktNext endpoint 2026-09-10 08:59:49 +02:00
chfhtw f6efef36ef save button in front 2026-09-10 08:58:37 +02:00
chfhtw 460d2745bc Remove unused stuff 2026-09-03 11:16:08 +02:00
chfhtw 5909f55b0e Add setup config 2026-09-03 11:15:36 +02:00
chfhtw 41ceb955ff Allow separate component for dashboard item config 2026-09-03 11:03:47 +02:00
chfhtw 3f8ffa05f1 bugfix: create deep copy instead of shallow for dashboard item config 2026-09-03 11:03:04 +02:00
chfhtw ef8090bf2e Add possibility to add css classes to the label in Form/Input 2026-09-03 11:01:09 +02:00
chfhtw 7b7edaf46a bugfix: bootstrap overwritten in tabulator CSS 2026-09-03 11:00:15 +02:00
Ivymaster 5fff45e302 Added check for date existence 2026-08-27 11:00:43 +02:00
chfhtw 00b0992fb8 Report widget (KPI, first draft) 2026-08-25 12:40:10 +02:00
chfhtw 74bcfe3c8d Widget admistration (first draft) 2026-08-25 12:39:33 +02:00
chfhtw e2a2d28f90 optionally use container div height in verticalsplit 2026-08-25 12:36:10 +02:00
Johann Hoffmann d40040efd8 added new fkey to tbl_widget for system.tbl_berechtigung; check for permission status during load & in widget picker; if widget was already picked/configured but not permitted anymore -> show a generalized "missing permission" screen via fallback component during widget init; 2026-07-21 18:14:26 +02:00
63 changed files with 4677 additions and 48 deletions
+14 -8
View File
@@ -170,13 +170,6 @@ $config['navigation_header'] = array(
'expand' => true,
'sort' => 51,
'requiredPermissions' => 'vertrag/mitarbeiter:r'
),
'studierendenverwaltung' => array(
'link' => site_url('studentenverwaltung'),
'description' => 'Studierendenverwaltung',
'expand' => true,
'sort' => 52,
'requiredPermissions' => ['admin:r', 'assistenz:r']
)
)
),
@@ -385,8 +378,21 @@ $config['navigation_menu']['apps'] = [
],
'lav' => [
'link' => site_url('lehre/lehrauftrag/Lehrauftrag/Dashboard'),
'description' => 'Lehrauftragsverwaltung',
'description' => 'Lehraufträge',
#'icon' => 'person-chalkboard',
'requiredPermissions' => array('lehre/lehrauftrag_bestellen:r', 'lehre/lehrauftrag_erteilen:r')
]
];
$config['navigation_menu']['dashboard/*'] = [
'dashboards' => [
'link' => site_url('dashboard/Admin'),
'description' => 'Dashboard',
'requiredPermissions' => 'dashboard/admin:rw'
],
'widgets' => [
'link' => site_url('dashboard/Admin/widgets'),
'description' => 'Widgets',
'requiredPermissions' => 'dashboard/admin:rw'
]
];
@@ -0,0 +1,258 @@
<?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');
use \DateTime as DateTime;
class Report extends FHCAPI_Controller
{
/**
* Object initialization
*/
public function __construct()
{
parent::__construct([
'list' => [ 'dashboard/admin:rw', 'basis/statistik:rw' ],
'vars' => self::PERM_LOGGED, // additional permission check inside
'get' => self::PERM_LOGGED, // additional permission check inside
]);
$this->load->model('organisation/Statistik_model', 'StatistikModel');
}
//------------------------------------------------------------------------------------------------------------------
// Public methods
/**
* @access public
*/
public function list()
{
$this->StatistikModel->addSelect('statistik_kurzbz');
$this->StatistikModel->addSelect('bezeichnung');
$this->StatistikModel->addSelect('gruppe');
$result = $this->StatistikModel->loadWhere([
'sql IS NOT NULL' => null
]);
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
/**
* @access public
*/
public function vars($statistik_kurzbz)
{
$result = $this->StatistikModel->load($statistik_kurzbz);
$statistik = $this->getDataOrTerminateWithError($result);
if (!$statistik)
show_404();
$statistik = current($statistik);
// NOTE(chris): Permission check
$permissions = [ 'vars' => $statistik->berechtigung_kurzbz . ':r' ];
if (!$this->permissionlib->isEntitled($permissions, 'vars')) {
$this->_outputAuthError($permissions);
exit; // immediately terminate the execution
}
$this->addMeta('berechtigung_kurzbz', $statistik->berechtigung_kurzbz);
$vars = $this->loadVars($statistik->sql);
$this->terminateWithSuccess($vars);
}
/**
* @access protected
*/
protected function loadVars($sql)
{
$result = [];
preg_match_all('/\$\w+/', $sql ?: '', $result);
$result = $result[0];
$vars = array_unique($result); // remove doubles
$this->load->model('system/Filter_model', 'FilterModel');
$vars = array_map([$this, 'varToFilter'], $vars); // prep them
$vars = array_filter($vars); // remove null values (former: $user)
return array_values($vars); // reindex
}
/**
* @access protected
*/
protected function varToFilter($value)
{
if ($value == '$user')
return null;
$value = str_replace('$', '', $value);
$result = $this->FilterModel->loadWhere([
'kurzbz' => $value
]);
$filter = $this->getDataOrTerminateWithError($result);
$result = [];
if (!$filter) {
$result = [
'kurzbz' => $value,
'title' => $value,
'type' => 'text',
];
} else {
$filter = current($filter);
$result = [
'kurzbz' => $filter->kurzbz,
'title' => $filter->bezeichnung ?: $filter->kurzbz,
'type' => $filter->type,
];
switch ($filter->type) {
case 'select':
$sql = str_replace('$user', $this->FilterModel->escape(getAuthUID()), $filter->sql);
$query = $this->FilterModel->execReadOnlyQuery($sql);
$options = $this->getDataOrTerminateWithError($query);
$showValue = $filter->showvalue;
$result['options'] = array_map(function ($option) use ($showValue) {
$label = '';
if ($showValue)
$label = '(' + $option->value + ') - ';
if (property_exists($option, 'name'))
$label .= $option->name;
else
$label .= ((array)$option)[array_find(array_keys((array)$option), function ($key) {
return $key != 'value';
})];
return [
'value' => $option->value,
'label' => $label,
];
}, $options);
if (preg_match('/(^|\s)multiple($|\s)/', $filter->htmlattr ?: '')) {
$result['multiple'] = true;
}
break;
default:
if (preg_match('/(^|\s)placeholder="([^"]*)"($|\s)/', $filter->htmlattr ?: '', $matches)) {
$result['placeholder'] = $matches[2];
}
break;
}
}
return $result;
}
/**
* @access public
*/
public function get($statistik_kurzbz)
{
$result = $this->StatistikModel->load($statistik_kurzbz);
$statistik = $this->getDataOrTerminateWithError($result);
if (!$statistik)
show_404();
$statistik = current($statistik);
// NOTE(chris): Permission check
$permissions = [ 'get' => $statistik->berechtigung_kurzbz . ':r' ];
if (!$this->permissionlib->isEntitled($permissions, 'get')) {
$this->_outputAuthError($permissions);
exit; // immediately terminate the execution
}
$vars = $this->loadVars($statistik->sql);
$vars_values = [];
if (count($vars)) {
$this->load->library('form_validation');
foreach ($vars as $var) {
$key = $var['kurzbz'];
$label = $var['title'];
$checks = 'required';
if (isset($var['multiple']) && $var['multiple']) {
$key .= '[]';
}
if ($var['type'] == 'select') {
$options = array_map(function ($opt) {
return $opt['value'];
}, $var['options']);
$checks .= '|strval|in_list[' . implode(',', $options) . ']';
}
if ($var['type'] == 'datepicker')
$checks .= '|is_valid_date';
$this->form_validation->set_rules($key, $label, $checks);
$vars_values[$var['kurzbz']] = $this->input->post($var['kurzbz']);
if ($var['type'] == 'datepicker') {
try {
$dt = new DateTime($vars_values[$var['kurzbz']]);
$vars_values[$var['kurzbz']] = $dt->format('c');
} catch(Exception $e) {
}
}
}
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
}
$db = $this->StatistikModel;
$sql = preg_replace_callback('/\$\w+/', function ($match) use ($db, $vars_values) {
if ($match[0] == '$user')
return $db->escape(getAuthUID());
$key = substr($match[0], 1);
if (isset($vars_values[$key])) {
$value = $vars_values[$key];
if (is_array($value)) {
$value = implode(', ', array_map(function ($v) use ($db) {
return $db->escape($v);
}, $value));
} else {
$value = $db->escape($value);
}
return $value;
}
return $match[0];
}, $statistik->sql);
$result = $this->StatistikModel->execReadOnlyQuery($sql);
$data = $this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess($data);
}
}
@@ -18,6 +18,8 @@
if (! defined('BASEPATH')) exit('No direct script access allowed');
use \DirectoryIterator as DirectoryIterator;
/**
* This controller operates between (interface) the JS (GUI) and the back-end
* Provides data to the ajax get calls about the users dashboard
@@ -31,7 +33,13 @@ class Widget extends FHCAPI_Controller
'get' => ['dashboard/benutzer:r', 'dashboard/admin:r'],
'list' => 'dashboard/admin:r',
'listAllowed' => ['dashboard/benutzer:rw', 'dashboard/admin:r'],
'setAllowed' => 'dashboard/admin:rw'
'listAll' => 'dashboard/admin:r',
'listAllOriginal' => 'dashboard/admin:rw',
'setAllowed' => 'dashboard/admin:rw',
'create' => 'dashboard/admin:rw',
'update' => 'dashboard/admin:rw',
'remove' => 'dashboard/admin:rw',
'generators' => 'dashboard/admin:rw',
]);
// Libraries
@@ -101,6 +109,41 @@ class Widget extends FHCAPI_Controller
$tmpsetup = json_decode($widget->setup);
$tmpsetup->file = absoluteJsImportUrl($tmpsetup->file);
$widget->setup = $tmpsetup;
$widget->permitted = empty($widget->berechtigung_kurzbz)
|| $this->permissionlib->isBerechtigt($widget->berechtigung_kurzbz);
return $widget;
}, $widgets);
$this->terminateWithSuccess($widgets);
}
public function listAll()
{
$result = $this->WidgetModel->load();
$widgets = $this->getDataOrTerminateWithError($result);
$widgets = array_map(function ($widget) {
$widget->arguments = json_decode($widget->arguments);
$tmpsetup = json_decode($widget->setup);
$tmpsetup->file = absoluteJsImportUrl($tmpsetup->file);
$widget->setup = $tmpsetup;
return $widget;
}, $widgets);
$this->terminateWithSuccess($widgets);
}
public function listAllOriginal()
{
$result = $this->WidgetModel->load();
$widgets = $this->getDataOrTerminateWithError($result);
$widgets = array_map(function ($widget) {
$widget->arguments = json_decode($widget->arguments);
$widget->setup = json_decode($widget->setup);
return $widget;
}, $widgets);
@@ -134,4 +177,185 @@ class Widget extends FHCAPI_Controller
$this->terminateWithSuccess($data);
}
public function create()
{
$this->loadPhrases([ 'ui', 'dashboard' ]);
$this->load->library('form_validation');
$this->form_validation->set_rules(
'widget_kurzbz',
$this->p->t('dashboard', 'widget_kurzbz'),
'required|is_not_in_db[dashboard/Widget_model:widget_kurzbz]',
[
'is_not_in_db' => $this->p->t('ui', 'error_entryExisting')
]
);
$this->form_validation->set_rules(
'setup[generator]',
$this->p->t('dashboard', 'generator'),
'required'
);
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$setup = $this->input->post('setup');
$file = explode(':', $setup['generator']);
if (count($file) == 1) {
$setup['file'] = 'public/js/components/DashboardWidget/' . $file[0] . '.js';
} else {
$setup['file'] = 'public/extensions/' . $file[0] . '/js/components/DashboardWidget/' . $file[1] . '.js';
}
$data = [
'widget_kurzbz' => $this->input->post('widget_kurzbz'),
'setup' => json_encode($setup),
'arguments' => json_encode([]),
];
$result = $this->WidgetModel->insert($data);
$data['widget_id'] = $this->getDataOrTerminateWithError($result);
$data['setup'] = $setup;
$data['arguments'] = [];
$this->terminateWithSuccess($data);
}
public function update()
{
$this->loadPhrases([ 'dashboard' ]);
$this->load->library('form_validation');
$this->form_validation->set_rules(
'widget_id',
$this->p->t('dashboard', 'widget_id'),
'required'
);
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$widget_id = $this->input->post('widget_id');
$data = [];
foreach ([
'widget_kurzbz',
'beschreibung',
'setup',
'arguments',
'berechtigung_kurzbz',
] as $post) {
$value = $this->input->post($post);
if ($value) {
if ($post == 'setup' || $post == 'arguments')
$value = json_encode($value);
$data[$post] = $value;
}
}
if (!$data)
$this->terminateWithSuccess();
$result = $this->WidgetModel->update($widget_id, $data);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess();
}
public function remove()
{
$this->loadPhrases([ 'dashboard' ]);
$this->load->library('form_validation');
$this->form_validation->set_rules(
'widget_id',
$this->p->t('dashboard', 'widget_id'),
'required'
);
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$widget_id = $this->input->post('widget_id');
$result = $this->WidgetModel->load($widget_id);
$widget = $this->getDataOrTerminateWithError($result);
$widget = current($widget);
if (!$widget)
show_404();
$setup = json_decode($widget->setup);
if (!property_exists($setup, 'generator'))
$this->terminateWithError($this->p->t('dashboard', 'widget_error_delete_native'), null, REST_Controller::HTTP_METHOD_NOT_ALLOWED);
$result = $this->WidgetModel->delete($widget_id);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess();
}
public function generators()
{
$result = $this->getGeneratorsRecursive();
$this->load->library('ExtensionsLib');
$extensions = $this->extensionslib->getInstalledExtensions();
if (hasData($extensions)) {
foreach (getData($extensions) as $extension) {
$result = array_merge($result, $this->getGeneratorsRecursive($extension->name));
}
}
return $this->terminateWithSuccess($result);
}
protected function getGeneratorsRecursive($extension = '', $path = '')
{
$result = [];
$dashboard_widget_path = 'public/js/components/DashboardWidget/';
$name = '';
$file_path = $dashboard_widget_path . $path;
$generator_path = $dashboard_widget_path . 'Generators/' . $path;
if ($extension) {
$name = $extension . ':';
$file_path = APPPATH . 'extensions/' . $extension . '/' . $file_path;
$generator_path = APPPATH . 'extensions/' . $extension . '/' . $generator_path;
} else {
$file_path = FHCPATH . $file_path;
$generator_path = FHCPATH . $generator_path;
}
$name .= $path;
$generator_path = str_replace('/', DIRECTORY_SEPARATOR, $generator_path);
if (is_dir($generator_path)) {
$dir = new DirectoryIterator($generator_path);
foreach ($dir as $file) {
if ($file->isDot())
continue;
$filename = $file->getFilename();
if ($file->isDir()) {
if (is_dir(str_replace('/', DIRECTORY_SEPARATOR, $file_path . $filename))) {
$res = $this->getGeneratorsRecursive($extension, $path . $filename . '/');
$result = array_merge($result, $res);
}
} else {
if (file_exists(str_replace('/', DIRECTORY_SEPARATOR, $file_path . $filename))) {
if ($file->getExtension() == 'js')
$result[] = $name . $file->getBasename('.js');
}
}
}
}
return $result;
}
}
@@ -23,7 +23,8 @@ class StudiengangEP extends FHCAPI_Controller
{
parent::__construct(
array(
'getStudiengangByKz' => self::PERM_LOGGED
'getStudiengangByKz' => self::PERM_LOGGED,
'getAllowed' => self::PERM_LOGGED,
)
);
// Load model StudiengangModel
@@ -54,4 +55,29 @@ class StudiengangEP extends FHCAPI_Controller
}
$this->terminateWithSuccess($stg);
}
/**
* Get all STGs they user has the given permissions for.
*
* @return void
*/
public function getAllowed()
{
$this->loadPhrases([ 'global' ]);
$this->load->library('form_validation');
$this->form_validation->set_rules('permissions[]', $this->p->t('global', 'permissions'), 'required');
if (!$this->form_validation->run())
$this->terminateWithValidationErrors($this->form_validation->error_array());
$permissions = $this->input->post('permissions');
$permittedStgs = [];
foreach ($permissions as $permission)
$permittedStgs = array_merge($permittedStgs, $this->permissionlib->getSTG_isEntitledFor($permission) ?: []);
$this->terminateWithSuccess($permittedStgs);
}
}
@@ -24,7 +24,8 @@ class Studienjahr extends FHCAPI_Controller
parent::__construct(
array(
'getAll' => self::PERM_LOGGED,
'getNext' => self::PERM_LOGGED
'getNext' => self::PERM_LOGGED,
'getAktNext' => self::PERM_LOGGED
)
);
// Load model StudiensemesterModel
@@ -77,4 +78,32 @@ class Studienjahr extends FHCAPI_Controller
$this->terminateWithSuccess(current(getData($result)));
}
public function getAktNext()
{
$this->StudienjahrModel->addJoin('public.tbl_studiensemester', 'studienjahr_kurzbz');
$this->StudienjahrModel->addOrder('ende');
$this->StudienjahrModel->addLimit(1);
$result = $this->StudienjahrModel->loadWhere([
'start <=' => 'NOW()',
'ende >=' => 'NOW()'
]);
$data = $this->getDataOrTerminateWithError($result);
if (!$data) {
$this->StudienjahrModel->addJoin('public.tbl_studiensemester', 'studienjahr_kurzbz');
$this->StudienjahrModel->addOrder('ende');
$this->StudienjahrModel->addLimit(1);
$result = $this->StudienjahrModel->loadWhere([
'ende >=' => 'NOW()'
]);
$data = $this->getDataOrTerminateWithError($result);
}
$this->terminateWithSuccess(current($data));
}
}
@@ -31,6 +31,7 @@ class Admin extends Auth_Controller
parent::__construct(
array(
'index' => 'dashboard/admin:rw',
'widgets' => 'dashboard/admin:rw',
'preview' => 'dashboard/admin:r',
)
);
@@ -43,6 +44,11 @@ class Admin extends Auth_Controller
$this->load->view('dashboard/admin.php', []);
}
public function widgets()
{
$this->load->view('dashboard/widgets.php');
}
public function preview($dashboard_kurzbz = 'CIS')
{
$this->load->view('dashboard/preview.php', [
+11
View File
@@ -529,6 +529,17 @@ function is_in_db($key, $model = '')
return (isSuccess($result) && hasData($result));
}
/**
* check if an entry does not exist in the database
*/
function is_not_in_db($key, $model = '')
{
if (!$model)
return false;
return !is_in_db($key, $model);
}
/**
* is building an array for Dropdown Entry in Print Dropdown
* @param $id id for the Document to add to the Document Array
@@ -10,6 +10,7 @@ $includesArray = array(
'primevue3' => true,
'skipID' => '#fhccontent',
'vuedatepicker11' => true,
'highcharts' => true,
'customCSSs' => array(
'public/css/components/verticalsplit.css',
'public/css/components/searchbar/searchbar.css',
+33
View File
@@ -0,0 +1,33 @@
<?php
$this->load->view(
'templates/FHC-Header',
array(
'title' => 'FH-Complete',
'bootstrap5' => true,
'fontawesome6' => true,
'axios027' => true,
'restclient' => true,
'vue3' => true,
'tabulator6' => true,
'primevue3' => true,
'vuedatepicker11' => true,
'customJSs' => [
'vendor/npm-asset/primevue/colorpicker/colorpicker.js',
'vendor/moment/luxonjs/luxon.min.js'
],
'customJSModules' => ['public/js/apps/Dashboard/WidgetAdmin.js'],
'customCSSs' => [
'public/css/Fhc.css',
'public/css/components/verticalsplit.css',
'public/css/components/dashboard.css',
'public/css/components/primevue.css',
'public/css/components/vue-datepicker.css',
],
'navigationcomponent' => true
)
);
?>
<div id="main"></div>
<?php $this->load->view('templates/FHC-Footer'); ?>
+25 -2
View File
@@ -1,3 +1,26 @@
.fhc-page-grid {
height: 100vh;
display: grid;
grid: "header header" auto "nav main" 1fr / auto 1fr;
}
.fhc-page-grid > * {
grid-area: header;
}
.fhc-page-grid > .navbar-left-side {
grid-area: nav;
position: initial;
align-items: start;
}
.fhc-page-grid > .content {
grid-area: main;
margin: 0;
}
@media (max-width: 992px) {
.fhc-page-grid {
grid: "header main" auto "nav main" 1fr / auto 1fr;
}
}
.fhc-header {
display: flex;
flex-direction: row;
@@ -100,10 +123,10 @@
}
.modal-msg {
max-width: 1378px;
max-width: 1378px;
}
.modal-msg .modal-content {
height: 95vh;
height: 95vh;
}
.fhcSkipLink {
+1 -1
View File
@@ -99,7 +99,7 @@
}
/* special bootstrap5 styling for tableWidget and their accordion-item ::after content */
.accordion-button::after{
.tabulator .accordion-button::after{
content:none !important;
}
+1 -1
View File
@@ -94,7 +94,7 @@
}
/* special bootstrap5 styling for tableWidget and their accordion-item ::after content */
.accordion-button::after{
.tabulator .accordion-button::after{
content:none !important;
}
+4
View File
@@ -714,6 +714,10 @@
width: 2rem;
height: 2rem;
}
.input-group-text .p-colorpicker-preview {
width: 1.5rem;
height: 1.5rem;
}
.p-colorpicker-panel {
background: #212529;
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="g" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(139,179,29,1)" />
<stop offset="100%" stop-color="rgba(114,120,122,0)" />
</linearGradient>
</defs>
<rect x="90" y="322" width="80" height="90" fill="url(#g)" stroke="#72777A" stroke-width="3" />
<rect x="216" y="212" width="80" height="200" fill="url(#g)" stroke="#72777A" stroke-width="3" />
<rect x="342" y="100" width="80" height="312" fill="url(#g)" stroke="#72777A" stroke-width="3" />
<path d="M75,370l150,-150l50,50l120,-120l-10,-10l45,-15l-15,45l-10,-10l-130,130l-50,-50l-140,140" fill="rgb(0, 100, 156)" stroke="rgb(0, 100, 156)" stroke-width="5" />
</svg>

After

Width:  |  Height:  |  Size: 739 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<clipPath id="c">
<path d="M92,320A1,1,0,0,1,420,192l41,-16A1,1,0,1,0,51,336z"/>
</clipPath>
</defs>
<text x="256" style="fill: rgb(0, 100, 156); font-family: &quot;Arial&quot;, sans-serif; font-size: 150px; font-weight: 700;text-anchor: middle;" y="300">KPI</text>
<foreignObject width="512" height="512" clip-path="url(#c)">
<div xmlns="http://www.w3.org/1999/xhtml" style="width: 100%;height: 100%;background: conic-gradient(from -112deg, rgba(114,120,122,0),rgba(139,179,29,1));"></div>
</foreignObject>
<path fill="transparent" d="M92,320A1,1,0,0,1,420,192l41,-16A1,1,0,1,0,51,336" stroke="#72777A" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

+40 -1
View File
@@ -34,6 +34,18 @@ export default {
url: '/api/frontend/v1/dashboard/widget/listAllowed/' + dashboard
};
},
listAll() {
return {
method: 'get',
url: '/api/frontend/v1/dashboard/widget/listAll'
};
},
listAllOriginal() {
return {
method: 'get',
url: '/api/frontend/v1/dashboard/widget/listAllOriginal'
};
},
setAllowed(dashboard_id, widget_id, allowed) {
return {
method: 'post',
@@ -42,5 +54,32 @@ export default {
dashboard_id, widget_id, allowed
}
};
}
},
create(params) {
return {
method: 'post',
url: '/api/frontend/v1/dashboard/widget/create',
params
};
},
update(params) {
return {
method: 'post',
url: '/api/frontend/v1/dashboard/widget/update',
params
};
},
remove(widget_id) {
return {
method: 'post',
url: '/api/frontend/v1/dashboard/widget/remove',
params: { widget_id }
};
},
generators() {
return {
method: 'get',
url: '/api/frontend/v1/dashboard/widget/generators'
};
},
};
+38
View File
@@ -0,0 +1,38 @@
/**
* 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 {
list() {
return {
method: 'get',
url: '/api/frontend/v1/report/list',
};
},
vars(statistik_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/report/vars/' + statistik_kurzbz,
};
},
get(statistik_kurzbz, params) {
return {
method: 'post',
url: '/api/frontend/v1/report/get/' + statistik_kurzbz,
params,
};
},
};
+11 -1
View File
@@ -28,5 +28,15 @@ export default {
url: '/api/frontend/v1/organisation/StudiengangEP/getStudiengangByKz',
params: { studiengang_kz }
};
}
},
getAllowed(permissions) {
if (!Array.isArray(permissions))
permissions = permissions.split(',');
return {
method: 'post',
url: '/api/frontend/v1/organisation/StudiengangEP/getAllowed',
params: { permissions }
};
},
};
+25
View File
@@ -0,0 +1,25 @@
/**
* 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 {
getAktNext() {
return {
method: 'get',
url: '/api/frontend/v1/organisation/studienjahr/getAktNext',
};
},
};
+2
View File
@@ -1,5 +1,6 @@
import FhcDashboard from '../../components/Dashboard/Dashboard.js';
import PluginsPhrasen from '../../plugins/Phrasen.js';
import PluginsCharts from '../../plugins/highchartsVue.js';
import Theme from '../../plugins/Theme.js';
import contrast from '../../directives/contrast.js';
import {setScrollbarWidth} from "../../helpers/CssVarCalcHelpers.js";
@@ -348,6 +349,7 @@ app.use(PluginsPhrasen);
app.use(Theme);
app.directive('contrast', contrast);
app.mount('#fhccontent');
app.use(PluginsCharts);
router.afterEach((to, from, failure) => {
app.config.globalProperties.$api.call(ApiRouteInfo.info('cis4', to.fullPath));
+43
View File
@@ -0,0 +1,43 @@
import { CoreNavigationCmpt } from '../../components/navigation/Navigation.js';
import WidgetAdmin from '../../components/Dashboard/WidgetAdmin.js';
import PluginsPhrasen from '../../plugins/Phrasen.js';
const app = Vue.createApp({
name: 'WidgetsAdminApp',
data: () => ({
appSideMenuEntries: {}
}),
components: {
CoreNavigationCmpt,
WidgetAdmin
},
provide() {
return {
timezone: FHC_JS_DATA_STORAGE_OBJECT.timezone
};
},
template: /* html */`
<div class="widgets-admin-app fhc-page-grid">
<core-navigation-cmpt :add-side-menu-entries="appSideMenuEntries" />
<div class="content d-flex flex-column gap-3 w-100 h-100 overflow-hidden">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3">
<h1 class="h2 pb-3 border-bottom mb-0 w-100">
{{ $p.t('dashboard/widgets') || '&nbsp;' }}
</h1>
</div>
<widget-admin class="h-100 overflow-hidden" />
</div>
</div>
`
});
app.use(primevue.config.default, {
zIndex: {
overlay: 9000,
tooltip: 8000
}
})
app.use(PluginsPhrasen);
app.directive('tooltip', primevue.tooltip);
app.mount('#main');
+9 -3
View File
@@ -6,10 +6,16 @@ export const FhcChart = {
type: Object,
}
},
template: `
methods: {
reflow() {
if (this.$refs.chart?.chart)
this.$refs.chart.chart.reflow();
},
},
template: /* html */`
<div style="width:100%;height:100%;overflow:auto">
<figure>
<highcharts class="chart" :options="chartOptions"></highcharts>
<figure class="h-100 m-0">
<highcharts ref="chart" class="chart h-100" :options="chartOptions"></highcharts>
</figure>
</div>
`
@@ -2,15 +2,16 @@ export default {
props: {
data: {
type: String,
}
},
},
data() {
return {}
return {};
},
template: /*html*/`
template: /*html*/ `
<div class="card">
<div class="card-body">
<span >{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-if="data">{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-else>{{$p.t('profil','fhAusweisStatusNichtAusgegeben')}}</span>
</div>
</div>`,
}
};
+21 -7
View File
@@ -1,5 +1,6 @@
import BsModal from "../Bootstrap/Modal.js";
import HeightTransition from "../Tranistion/HeightTransition.js";
import MissingPermissionWidget from "../DashboardWidget/MissingPermission.js";
import { enableDragDropTouch } from "../../../../vendor/drag-drop-touch-js/dragdroptouch/dist/drag-drop-touch.esm.min.js";
@@ -112,6 +113,12 @@ export default {
isPinned() {
return this.place?.pinned ? true : false;
},
permitted() {
// widgets without a linked permission (or before the template loaded)
// are considered permitted; the backend sets permitted === false only
// when the user is missing the widget's required permission
return this.widgetTemplate?.permitted !== false;
},
ready() {
return this.component && this.arguments !== null;
}
@@ -138,8 +145,11 @@ export default {
handleHideBsModal() {
this.$emit('configClosed')
},
createTmpConfig() {
this.tmpConfig = JSON.parse(JSON.stringify(this.arguments));
},
openConfig() {
this.tmpConfig = { ...this.arguments };
this.createTmpConfig();
this.$refs.config.show();
},
setConfig(hasConfig) {
@@ -156,7 +166,7 @@ export default {
},
sendChangeConfig(config) {
for (var k in config) {
if (this.widgetTemplate.arguments[k] == config[k]) {
if (JSON.stringify(this.widgetTemplate.arguments[k]) == JSON.stringify(config[k])) {
delete config[k];
}
}
@@ -169,18 +179,22 @@ export default {
&& this.widgetTemplate.widget_id
&& this.widgetTemplate.arguments
) {
let component = (await import(this.widgetTemplate.setup.file)).default;
// render the "missing permission" screen instead of the actual
// widget when the user does not hold the widget's linked permission
let component = this.permitted
? (await import(this.widgetTemplate.setup.file)).default
: MissingPermissionWidget;
this.$options.components["widget" + this.widgetTemplate.widget_id] = component;
this.component = "widget" + this.widgetTemplate.widget_id;
this.arguments = { ...this.widgetTemplate.arguments, ...this.config };
this.tmpConfig = { ...this.arguments };
this.createTmpConfig();
}
}
},
watch: {
config() {
this.arguments = { ...this.widgetTemplate?.arguments, ...this.config };
this.tmpConfig = { ...this.arguments };
this.createTmpConfig();
this.$refs.config && this.$refs.config.hide();
this.isLoading = false;
},
@@ -269,7 +283,7 @@ export default {
</template>
<!-- widget link -->
<a
v-if="widgetTemplate.setup.cis4link"
v-if="widgetTemplate.setup.cis4link && permitted"
:href="getWidgetC4Link(widgetTemplate)"
class="col-auto ms-auto"
:aria-label="$p.t('dashboard/widget_link')"
@@ -331,7 +345,7 @@ export default {
</template>
<template v-slot:default>
<component
:is="component"
:is="hasConfig !== true ? hasConfig : component"
v-if="ready && !isLoading"
v-model:shared-data="sharedData"
:config="tmpConfig"
@@ -14,10 +14,14 @@ export default {
callbacks: {}
}),
computed: {
// filter widgets away the user has no permissions for
availableWidgets() {
return (this.widgets || []).filter(widget => widget.permitted !== false);
},
hasAnyWidgets() {
if (!this.widgets)
return false;
if (!this.widgets.length && !this.hiddenWidgets?.length)
if (!this.availableWidgets.length && !this.hiddenWidgets?.length)
return false;
return true;
}
@@ -54,7 +58,7 @@ export default {
<template v-slot:default>
<template v-if="widgets && hiddenWidgets">
<div
v-if="!widgets.length && !hiddenWidgets.length"
v-if="!availableWidgets.length && !hiddenWidgets.length"
class="row g-2"
>
<div>{{ $p.t('dashboard/noWidgetsAvailable') }}</div>
@@ -74,16 +78,17 @@ export default {
</div>
</div>
<div
v-if="widgets.length"
v-if="availableWidgets.length"
class="row g-2"
>
<div
v-for="widget in widgets"
<template
v-for="widget in availableWidgets"
:key="widget.widget_id"
class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2"
>
<widget-icon @select="pick" :widget="widget"></widget-icon>
</div>
<div v-if="widget.permitted" class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2">
<widget-icon @select="pick" :widget="widget"></widget-icon>
</div>
</template>
</div>
</template>
</template>
@@ -0,0 +1,87 @@
import VerticalSplit from "../verticalsplit/verticalsplit.js";
import WidgetNew from "./WidgetAdmin/New.js";
import WidgetList from "./WidgetAdmin/List.js";
import WidgetEdit from "./WidgetAdmin/Edit.js";
export default {
name: 'WidgetAdmin',
components: {
VerticalSplit,
WidgetNew,
WidgetList,
WidgetEdit,
},
provide() {
return {
imageSrc(src) {
if (!src)
return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'skin/images/fh_technikum_wien_illustration_klein.png';
else if (src[0] == '/')
return FHC_JS_DATA_STORAGE_OBJECT.app_root + src.substr(1);
return src;
},
};
},
data() {
return {
originalData: null,
currentData: null,
};
},
computed: {
unsavedProgress() {
if (JSON.stringify(this.originalData) != JSON.stringify(this.currentData))
return true;
return false;
},
},
methods: {
select(data) {
this.originalData = data;
this.currentData = JSON.parse(JSON.stringify(data));
},
create(data) {
data.arguments = {};
this.currentData = JSON.parse(JSON.stringify(data));
this.save();
},
remove(widget) {
this.$refs.list.$refs.table.tabulator.deleteRow(widget.widget_id);
this.originalData = this.currentData = null;
},
save() {
this.select(this.currentData);
this.$nextTick(() => this.$refs.list.updateOrAddDataAndSelect(this.originalData));
},
},
template: /* html */`
<div class="widgets-admin">
<widget-new ref="new" @create="create" />
<vertical-split class="h-100 position-relative" use-div-height>
<template #top>
<widget-list
ref="list"
:unsaved-progress="unsavedProgress"
@new="$refs.new?.show"
@select="select"
/>
</template>
<template #bottom>
<widget-edit
v-if="currentData"
ref="edit"
:key="currentData.widget_id"
v-model="currentData"
:original-data="originalData"
:unsaved-progress="unsavedProgress"
@remove="remove"
@saved="save"
/>
<div v-else class="h-100 d-flex justify-content-center align-items-center">
{{ $p.t('dashboard/no_widget_selected') }}
</div>
</template>
</vertical-split>
</div>`
}
@@ -0,0 +1,141 @@
import CoreForm from "../../Form/Form.js";
import EditBasics from "./Edit/Basics.js";
import EditSetup from "./Edit/Setup.js";
import EditPermission from "./Edit/Permission.js";
import ApiWidget from "../../../api/factory/dashboard/widget.js";
export default {
name: 'WidgetsAdminEdit',
components: {
CoreForm,
EditBasics,
EditSetup,
EditPermission,
},
props: {
originalData: Object,
modelValue: Object,
unsavedProgress: Boolean,
},
emits: [
"saved",
"remove",
"update:modelValue",
],
data() {
return {
generator: null,
saving: false,
};
},
computed: {
value: {
get() {
return this.modelValue;
},
set(modelValue) {
this.$emit('update:modelValue', modelValue);
},
},
setup: {
get() {
return this.modelValue.setup;
},
set(setup) {
this.$emit('update:modelValue', { ...this.modelValue, setup });
},
},
permission: {
get() {
return this.modelValue.berechtigung_kurzbz;
},
set(berechtigung_kurzbz) {
this.$emit('update:modelValue', { ...this.modelValue, berechtigung_kurzbz });
},
},
},
watch: {
'originalData.setup.generator': {
async handler(gen) {
if (!gen)
return this.generator = null;
gen = gen.split(':').reverse();
let file = '../../DashboardWidget/Generators/' + gen[0] + '.js';
if (gen.length > 1)
file = '../../../../extensions/' + gen[1] + '/js/components' + file.substr(5);
this.generator = Vue.markRaw((await import(file)).default);
},
immediate: true,
}
},
methods: {
save() {
this.saving = true;
this.$refs.form.clearValidation();
const data = JSON.parse(JSON.stringify(this.modelValue));
this.$refs.form
.call(ApiWidget.update(data))
.then(() => {
this.$emit('update:modelValue', data);
this.$emit('saved');
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => this.saving = false);
},
remove() {
this.$fhcAlert.confirmDelete()
.then(result => {
if (!result)
return Promise.reject({ handled: true });
this.saving = true;
return this.$api
.call(ApiWidget.remove(this.modelValue.widget_id));
})
.then(() => {
this.$emit('remove', this.modelValue);
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => this.saving = false);
},
},
template: /* html */`
<core-form
v-if="modelValue"
ref="form"
class="widgets-admin-edit mx-2 pb-5"
@submit.prevent="save"
>
<edit-basics v-model="value" :original="originalData" class="border-bottom mb-3" />
<template v-if="generator">
<component
:is="generator"
v-model="value"
:original="originalData"
:key="modelValue.widget_id"
/>
</template>
<template v-else>
<edit-setup v-model="setup" class="border-bottom mb-3" />
<edit-permission v-model="permission" />
</template>
<div class="position-absolute mx-2 my-1 bottom-0 end-0 z-3">
<button
v-if="generator"
type="button"
:disabled="saving"
class="btn btn-danger me-2"
@click="remove"
>{{ $p.t('ui/loeschen') }}</button>
<button
type="submit"
:disabled="saving || !unsavedProgress"
class="btn btn-primary"
>{{ $p.t('ui/speichern') }}</button>
</div>
</core-form>
`
}
@@ -0,0 +1,55 @@
import FormInput from "../../../Form/Input.js";
export default {
name: 'WidgetsAdminEditBasics',
components: {
FormInput,
},
inject: {
imageSrc: "imageSrc",
},
props: {
original: Object,
modelValue: Object,
},
emits: [
"update:modelValue",
],
computed: {
img() {
return this.imageSrc(this.modelValue.setup?.icon);
},
},
template: /* html */`
<div class="widgets-admin-edit-basics">
<div class="d-flex gap-3 my-2">
<div class="col-auto">
<img
:src="img"
class="object-fit-contain"
style="height: calc(3em + 1.75rem + var(--bs-border-width) * 2 + 2 * var(--bs-body-font-size) * var(--bs-body-line-height)); width: calc(3em + 1.75rem + var(--bs-border-width) * 2 + 2 * var(--bs-body-font-size) * var(--bs-body-line-height))"
>
</div>
<div class="col">
<div class="d-flex gap-3 mb-2">
<div class="col">
<b>{{ $p.t('dashboard/widget_kurzbz') }}: </b>
{{ modelValue.widget_kurzbz }}
</div>
<div v-if="modelValue.widget_id !== undefined" class="col-auto">
<b>{{ $p.t('dashboard/widget_id') }}: </b>
{{ modelValue.widget_id }}
</div>
</div>
<div>
<form-input
:label="$p.t('global/beschreibung') + ':'"
type="textarea"
v-model="modelValue.beschreibung"
/>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,35 @@
import FormInput from "../../../Form/Input.js";
export default {
name: 'WidgetsAdminEditPermission',
components: {
FormInput,
},
props: {
modelValue: String,
disabled: Boolean,
},
emits: [
"update:modelValue"
],
computed: {
value: {
get() {
return this.modelValue;
},
set(modelValue) {
this.$emit('update:modelValue', modelValue);
},
},
},
template: /* html */`
<div class="widgets-admin-edit-permission">
<form-input
v-model="value"
type="text"
:label="$p.t('global/permission')"
:disabled="disabled"
/>
</div>
`
}
@@ -0,0 +1,101 @@
import ReportAggregator from './Aggregators/Aggregator.js';
let counter = 0;
export default {
name: "WidgetsGenteratorReportAggregators",
components: {
ReportAggregator,
},
provide() {
return {
defaultAggregatorIndex: Vue.computed({
get: () => this.defaultAggregatorIndex,
set: v => {
const curr = this.modelValue.find(agg => agg.default);
if (curr)
delete curr.default;
this.modelValue[v].default = true;
},
}),
};
},
props: {
modelValue: {
type: Object,
required: true
},
},
emits: [
"update:modelValue",
],
data() {
return {
myId: 0,
};
},
computed: {
defaultAggregatorIndex() {
return this.modelValue.findIndex(agg => agg.default);
}
},
methods: {
addAggregator() {
this.modelValue.push({});
},
removeAggregator(index) {
this.$emit('update:modelValue', this.modelValue.toSpliced(index, 1));
},
getHeaderStyle(isDefault) {
if (!isDefault)
return undefined;
return {
'--bs-accordion-btn-bg': 'var(--bs-success-bg-subtle)',
'--bs-accordion-btn-color': 'var(--bs-success-text-emphasis)',
'--bs-accordion-active-bg': 'var(--bs-success)',
'--bs-accordion-active-color': '#fff',
};
},
},
created() {
this.myId = counter++;
},
template: /*html*/ `
<div class="widgets-report-config-aggregators">
<label class="form-label">{{ $p.t('dashboard/widget_report_kpi_aggregators') }}</label>
<div class="accordion mb-2">
<div
v-for="(agg, i) in modelValue"
:key="i"
class="accordion-item"
>
<h3
class="accordion-header"
:style="getHeaderStyle(defaultAggregatorIndex == i)"
>
<button
type="button"
class="accordion-button collapsed"
data-bs-toggle="collapse"
:data-bs-target="'#aggregator_' + myId + '_' + i"
aria-expanded="false"
:aria-controls="'aggregator_' + myId + '_' + i"
>
<span v-if="agg.label">{{ agg.label }}</span>
<i v-else>{{ $p.t('ui/neu') }}</i>
</button>
</h3>
<div :id="'aggregator_' + myId + '_' + i" class="accordion-collapse collapse">
<report-aggregator v-model="agg" class="accordion-body" :index="i" @remove="removeAggregator" />
</div>
</div>
</div>
<div>
<button type="button" class="btn btn-primary" @click="addAggregator">
<i class="fa fa-plus" />
{{ $p.t('dashboard/widget_report_kpi_aggregator') }}
</button>
</div>
</div>
`,
};
@@ -0,0 +1,76 @@
import FormInput from '../../../../../Form/Input.js';
export default {
name: "WidgetsGenteratorReportAggregatorsAggregator",
components: {
FormInput,
},
inject: {
defaultAggregatorIndex: 'defaultAggregatorIndex',
},
props: {
modelValue: {
type: Object,
required: true
},
index: {
type: Number,
required: true
},
},
emits: [
"update:modelValue",
"remove",
],
methods: {
remove() {
this.$fhcAlert.confirmDelete()
.then(result => {
if (result)
this.$emit('remove', this.index);
})
.catch(this.$fhcAlert.handleSystemError);
},
},
template: /*html*/ `
<div class="widgets-report-config-aggregators-aggregator">
<form-input
v-model="modelValue.label"
type="text"
:label="$p.t('global/label')"
class="mb-3"
/>
<form-input
v-model="modelValue.type"
type="select"
:label="$p.t('global/typ')"
class="mb-3"
>
<option value="sum">{{ $p.t('dashboard/widget_report_kpi_aggregator_type_sum') }}</option>
<option value="count">{{ $p.t('dashboard/widget_report_kpi_aggregator_type_count') }}</option>
</form-input>
<form-input
v-if="modelValue.type == 'sum'"
v-model="modelValue.fields"
type="text"
:label="$p.t('dashboard/widget_report_kpi_aggregator_fields')"
:placeholder="$p.t('dashboard/widget_report_kpi_aggregator_fields_placeholder')"
class="mb-3"
/>
<div class="d-flex justify-content-between align-items-baseline">
<form-input
v-model="defaultAggregatorIndex"
type="radio"
name="aggregatordefault"
container-class="form-switch"
:label="$p.t('ui/default')"
:value="index"
/>
<button type="button" class="btn btn-danger" @click="remove">
<i class="fa-solid fa-trash"></i>
{{ $p.t('ui/loeschen') }}
</button>
</div>
</div>
`,
};
@@ -0,0 +1,56 @@
import FormInput from '../../../../Form/Input.js';
export default {
name: "WidgetsGenteratorReportAxis",
components: {
FormInput,
},
props: {
modelValue: {
type: Object,
required: true
},
},
emits: [
"update:modelValue",
],
computed: {
field: {
get() {
return this.modelValue?.field || '';
},
set(field) {
this.$emit('update:modelValue', { ...this.modelValue, field });
}
},
title: {
get() {
return this.modelValue?.title || '';
},
set(title) {
this.$emit('update:modelValue', { ...this.modelValue, title });
}
},
},
methods: {
addChart() {
this.modelValue.push({});
},
removeChart(index) {
this.$emit('update:modelValue', this.modelValue.toSpliced(index, 1));
},
},
template: /*html*/ `
<div class="widgets-report-config-axis">
<label class="form-label">{{ $p.t('dashboard/widget_report_graph_axis') }}</label>
<form-input
v-model="field"
:label="$p.t('dashboard/widget_report_graph_field')"
/>
<form-input
v-model="title"
:label="$p.t('global/label')"
/>
</div>
`,
};
@@ -0,0 +1,71 @@
import ReportChart from './Charts/Chart.js';
let counter = 0;
export default {
name: "WidgetsGenteratorReportCharts",
components: {
ReportChart,
},
props: {
modelValue: {
type: Object,
required: true
},
},
emits: [
"update:modelValue",
],
data() {
return {
myId: 0,
};
},
methods: {
addChart() {
this.modelValue.push({});
},
removeChart(index) {
this.$emit('update:modelValue', this.modelValue.toSpliced(index, 1));
},
},
created() {
this.myId = counter++;
},
template: /*html*/ `
<div class="widgets-report-config-charts">
<label class="form-label">{{ $p.t('dashboard/widget_report_graph_charts') }}</label>
<div class="accordion mb-2">
<div
v-for="(opt, i) in modelValue"
:key="i"
class="accordion-item"
>
<h3 class="accordion-header">
<button
type="button"
class="accordion-button collapsed"
data-bs-toggle="collapse"
:data-bs-target="'#chart_' + myId + '_' + i"
aria-expanded="false"
:aria-controls="'chart_' + myId + '_' + i"
>
<span v-if="opt.label">{{ opt.label }}</span>
<span v-else-if="opt.field">{{ opt.field }}</span>
<i v-else>{{ $p.t('ui/neu') }}</i>
</button>
</h3>
<div :id="'chart_' + myId + '_' + i" class="accordion-collapse collapse">
<report-chart v-model="modelValue[i]" class="accordion-body" @remove="removeChart(i)" />
</div>
</div>
</div>
<div>
<button type="button" class="btn btn-primary" @click="addChart">
<i class="fa fa-plus" />
{{ $p.t('dashboard/widget_report_graph_chart') }}
</button>
</div>
</div>
`,
};
@@ -0,0 +1,154 @@
import FormInput from '../../../../../Form/Input.js';
export default {
name: "WidgetsGenteratorReportChartsChart",
components: {
FormInput,
PvDropdown: primevue.dropdown,
PvColorpicker: primevue.colorpicker, // NOTE(chris): colorpicker must be loaded in view
},
props: {
modelValue: {
type: Object,
required: true
},
},
emits: [
"update:modelValue",
"remove",
],
data() {
return {
typeOptions: [
{
value: 'bar',
title: this.$p.t('dashboard/widget_report_graph_chart_type_bar'),
optionHtml: /* html */`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 10"><rect x="0" y="5" width="8" height="5" fill="black"/><rect x="11" y="2" width="8" height="8" fill="black"/><rect x="22" y="6" width="8" height="4" fill="black"/></svg>`,
},
{
value: 'line',
title: this.$p.t('dashboard/widget_report_graph_chart_type_line'),
optionHtml: /* html */`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 10"><path d="M0,5h30" stroke="black"/></svg>`,
},
{
value: 'dashed',
title: this.$p.t('dashboard/widget_report_graph_chart_type_dashed'),
optionHtml: /* html */`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 10"><path d="M0,5h30" stroke="black" stroke-dasharray="4 4"/></svg>`,
},
{
value: 'dotted',
title: this.$p.t('dashboard/widget_report_graph_chart_type_dotted'),
optionHtml: /* html */`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 10"><path d="M0,5h30" stroke="black" stroke-dasharray="0 4" stroke-linecap="round"/></svg>`,
},
],
};
},
computed: {
field: {
get() {
return this.modelValue.field || '';
},
set(field) {
this.$emit('update:modelValue', { ...this.modelValue, field });
}
},
label: {
get() {
return this.modelValue.label || '';
},
set(label) {
this.$emit('update:modelValue', { ...this.modelValue, label });
}
},
color: {
get() {
return this.modelValue.color || '';
},
set(color) {
this.$emit('update:modelValue', { ...this.modelValue, color });
}
},
text: {
get() {
return this.modelValue.text || '';
},
set(text) {
this.$emit('update:modelValue', { ...this.modelValue, text });
}
},
type: {
get() {
if (!this.modelValue.type)
return undefined;
return this.typeOptions.find(opt => opt.value == this.modelValue.type);
},
set({ value: type }) {
this.$emit('update:modelValue', { ...this.modelValue, type });
}
},
},
methods: {
remove() {
this.$fhcAlert.confirmDelete()
.then(result => {
if (result)
this.$emit('remove');
})
.catch(this.$fhcAlert.handleSystemError);
},
},
template: /*html*/ `
<div class="widgets-report-config-charts-chart">
<form-input
v-model="field"
:label="$p.t('dashboard/widget_report_graph_field')"
class="mb-3"
/>
<form-input
v-model="label"
:label="$p.t('global/label')"
class="mb-3"
/>
<label class="form-label">{{ $p.t('dashboard/widget_report_graph_chart_layout') }}</label>
<div class="input-group mb-3">
<span class="input-group-text">{{ $p.t('global/typ') }}</span>
<pv-dropdown
v-model="type"
:options="typeOptions"
>
<template #value="{ value, placeholder }">
<div
v-html="value ? value.optionHtml : placeholder"
style="width:3rem"
></div>
</template>
<template #option="{ option }">
<div v-html="option.optionHtml"></div>
</template>
</pv-dropdown>
<span
class="input-group-text"
:title="$p.t('dashboard/widget_report_graph_chart_bg_color')"
>
<i class="fa-solid fa-fill"></i>
</span>
<pv-colorpicker v-model="color" class="input-group-text"/>
<form-input v-model="color" input-group></form-input>
<span
class="input-group-text"
:title="$p.t('dashboard/widget_report_graph_chart_text_color')"
>
<i class="fa-solid fa-font"></i>
</span>
<pv-colorpicker v-model="text" class="input-group-text"/>
<form-input v-model="text" input-group></form-input>
</div>
<div class="d-flex justify-content-end align-items-baseline">
<button type="button" class="btn btn-danger" @click="remove">
<i class="fa-solid fa-trash"></i>
{{ $p.t('ui/loeschen') }}
</button>
</div>
</div>
`,
};
@@ -0,0 +1,89 @@
import FormInput from '../../../../Form/Input.js';
import PickerOption from './Picker/Option.js';
import ApiReport from '../../../../../api/factory/report.js';
export default {
name: "WidgetsGenteratorReportPicker",
components: {
FormInput,
PickerOption,
},
props: {
modelValue: {
type: String,
required: true
},
},
emits: [
"update:modelValue",
],
data() {
return {
allItems: null,
filteredItems: [],
selectedValue: '',
};
},
methods: {
initSelectedValue() {
if (this.modelValue) {
const res = this.allItems.find(item => item.statistik_kurzbz == this.modelValue);
if (res)
return this.selectedValue = res;
this.selectedValue = this.modelValue;
} else {
this.selectedValue = '';
}
},
searchItems(event) {
if (this.allItems) {
if (!event.query) {
this.filteredItems = this.allItems;
} else {
const regex = new RegExp(event.query, 'i');
this.filteredItems = this.allItems.filter(t => regex.test(t.bezeichnung) || regex.test(t.statistik_kurzbz) || regex.test(t.gruppe));
}
}
},
updateSelectedItem(v) {
if (v?.statistik_kurzbz) {
this.$emit('update:modelValue', v.statistik_kurzbz);
}
},
},
created() {
this.$api
.call(ApiReport.list())
.then(result => {
this.allItems = result.data;
this.initSelectedValue();
})
.catch(this.$fhcAlert.handleSystemErrors);
},
template: /*html*/ `
<div class="widgets-report-config-picker">
<form-input
type="autocomplete"
:label="$p.t('dashboard/widget_report_statistik')"
v-model="selectedValue"
:suggestions="filteredItems"
field="statistik_kurzbz"
dropdown-mode="blank"
dropdown
force-selection
@complete="searchItems"
@update:modelValue="updateSelectedItem"
>
<template #option="{ option }">
<picker-option :option="option" />
</template>
</form-input>
<div v-if="selectedValue?.statistik_kurzbz" class="text-muted">
{{ selectedValue.bezeichnung || selectedValue.statistik_kurzbz }}
</div>
</div>
`,
};
@@ -0,0 +1,18 @@
export default {
name: "WidgetsGenteratorReportPickerOption",
props: {
option: {
type: Object,
required: true
},
},
template: /*html*/ `
<div class="widgets-report-config-picker-option">
<span>{{ option.bezeichnung || option.statistik_kurzbz }} </span>
<small class="text-muted">
[<b v-if="option.gruppe" class="text-muted">{{ option.gruppe }}: </b>
{{ option.statistik_kurzbz }}]
</small>
</div>
`,
};
@@ -0,0 +1,33 @@
import ReportVar from './Vars/Var.js';
export default {
name: "WidgetsGenteratorReportVars",
components: {
ReportVar,
},
props: {
modelValue: {
type: Object,
required: true
},
details: {
type: Array,
required: true
},
},
emits: [
"update:modelValue",
],
template: /*html*/ `
<div class="widgets-report-config-vars">
<label v-if="details.length" class="form-label">{{ $p.t('dashboard/widget_report_vars') }}</label>
<template v-for="detail in details" :key="detail.kurzbz">
<report-var
:model-value="modelValue[detail.kurzbz]"
:detail="detail"
@update:model-value="$emit('update:modelValue', { ...modelValue, [detail.kurzbz]: $event })"
/>
</template>
</div>
`,
};
@@ -0,0 +1,37 @@
import FormInput from '../../../../../../Form/Input.js';
export default {
name: "WidgetsGenteratorReportVarsVarCalcDate",
components: {
FormInput,
},
props: {
modelValue: {
type: String,
default: ''
},
},
emits: [
"update:modelValue",
],
computed: {
modelValueCmp: {
get() {
return this.modelValue;
},
set(v) {
this.$emit('update:modelValue', v);
},
},
},
template: /*html*/ `
<form-input
v-model="modelValueCmp"
class="widgets-report-config-vars-var-calc-date"
type="text"
input-group
:placeholder="$p.t('dashboard/widget_report_vars_calc_date_placeholder')"
>
</form-input>
`,
};
@@ -0,0 +1,37 @@
import FormInput from '../../../../../../Form/Input.js';
export default {
name: "WidgetsGenteratorReportVarsVarCalcRight",
components: {
FormInput,
},
props: {
modelValue: {
type: String,
default: ''
},
},
emits: [
"update:modelValue",
],
computed: {
modelValueCmp: {
get() {
return this.modelValue;
},
set(v) {
this.$emit('update:modelValue', v);
},
},
},
template: /*html*/ `
<form-input
v-model="modelValueCmp"
class="widgets-report-config-vars-var-calc-right"
type="text"
input-group
:placeholder="$p.t('dashboard/widget_report_vars_calc_right_placeholder')"
>
</form-input>
`,
};
@@ -0,0 +1,163 @@
import FormInput from '../../../../../Form/Input.js';
import { useCalculatedVars } from '../../../../../../composables/DashboardWidget/Report/CalculatedVars.js';
import { capitalize } from '../../../../../../helpers/StringHelpers.js';
import ApiReport from '../../../../../../api/factory/report.js';
export default {
name: "WidgetsGenteratorReportVarsVar",
components: {
FormInput,
},
props: {
modelValue: {
type: Object,
required: true
},
detail: {
type: Object,
required: true
},
noType: {
type: Boolean,
default: false
},
},
emits: [
"update:modelValue",
],
computed: {
fixedInputAttrs() {
let attrs = {
type: this.detail.type,
};
if (this.detail.placeholder)
attrs.placeholder = this.detail.placeholder;
if (this.detail.multiple)
attrs.multiple = this.detail.multiple;
return attrs;
},
saveValue() {
if (this.fixedInputAttrs.multiple)
if (this.modelValue.value === undefined)
return [];
return this.modelValue.value;
},
extendedType() {
if (!this.modelValue?.type)
return [];
return this.modelValue.type.split(':');
},
calcTypeExtra: {
get() {
if (this.extendedType.length > 1)
return this.extendedType[2];
return undefined;
},
set(v) {
this.setType([this.extendedType[0], this.extendedType[1], v].join(':'));
},
},
calcComponent() {
if (this.noType)
return null;
if (this.extendedType.length < 2)
return null;
if (this.extendedType[0] != 'calc')
return null;
if (!this.calcComponents[this.extendedType[1]])
return null;
return this.calcComponents[this.extendedType[1]];
},
options() {
if (this.extendedType.length < 1 && !this.noType)
return [];
if (this.extendedType[0] == 'calc' && !this.noType)
return this.calcOptions;
if (this.detail.type == 'select')
return this.detail.options;
return [];
},
},
methods: {
getComponent(string) {
return 'Report' + capitalize(string);
},
setType(type) {
if (type == 'user' || type.substr(0, 5) == 'calc:') {
this.$emit('update:modelValue', { type, detail: this.detail });
} else {
this.$emit('update:modelValue', { type });
}
},
setSubType(subtype) {
let type = 'calc:' + subtype;
this.$emit('update:modelValue', { type, detail: this.detail });
},
setValue(value) {
this.modelValue.value = value;
this.$emit('update:modelValue', this.modelValue);
},
},
setup() {
const { components, options } = useCalculatedVars();
return {
calcComponents: components,
calcOptions: options,
};
},
created() {
if (!this.modelValue?.type) {
this.setType('fix');
}
},
template: /*html*/ `
<div class="widgets-report-config-vars-var input-group">
<span class="input-group-text">
{{ detail.title }}
</span>
<form-input
v-if="!noType"
type="select"
:modelValue="extendedType[0]"
container-class="d-flex"
input-group
@update:modelValue="setType"
>
<option value="fix">{{ $p.t('dashboard/widget_report_vars_var_type_fix') }}</option>
<option value="calc">{{ $p.t('dashboard/widget_report_vars_var_type_calc') }}</option>
<option value="user">{{ $p.t('dashboard/widget_report_vars_var_type_user') }}</option>
</form-input>
<form-input
v-if="modelValue.type == 'fix' || noType"
:modelValue="saveValue"
v-bind="fixedInputAttrs"
input-group
@update:modelValue="setValue"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</form-input>
<form-input
v-if="extendedType[0] == 'calc' && !noType"
:modelValue="extendedType[1]"
type="select"
input-group
@update:modelValue="setSubType"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</form-input>
<component v-if="calcComponent" :is="calcComponent" v-model="calcTypeExtra" />
</div>
`,
};
@@ -0,0 +1,47 @@
import FormInput from "../../../Form/Input.js";
import EditSize from "./Size.js";
export default {
name: 'WidgetsAdminEditSetup',
components: {
FormInput,
EditSize,
},
props: {
modelValue: Object,
editName: Boolean,
editSize: Boolean,
editHideFooter: Boolean,
},
emits: [
"update:modelValue"
],
template: /* html */`
<div class="widgets-admin-edit-setup">
<form-input
v-model="modelValue.name"
type="text"
container-class="mb-3"
:label="$p.t('global/name')"
:disabled="!editName"
/>
<label class="form-label">{{ $p.t('dashboard/widget_setup_size') }}</label>
<div class="d-flex gap-3 mb-3">
<edit-size v-model="modelValue.width" :disabled="!editSize">
{{ $p.t('dashboard/widget_setup_width') }}
</edit-size>
<edit-size v-model="modelValue.height" :disabled="!editSize">
{{ $p.t('dashboard/widget_setup_height') }}
</edit-size>
</div>
<form-input
v-model="modelValue.hideFooter"
type="checkbox"
container-class="form-switch mb-3"
role="switch"
:label="$p.t('dashboard/widget_setup_hidefooter')"
:disabled="!editHideFooter"
/>
</div>
`
}
@@ -0,0 +1,91 @@
import FormInput from "../../../Form/Input.js";
export default {
name: 'WidgetsAdminEditSize',
components: {
FormInput,
},
props: {
modelValue: [ Object, Number ],
disabled: Boolean,
},
emits: [
"update:modelValue"
],
computed: {
min: {
get() {
if (Number.isInteger(this.modelValue))
return this.modelValue;
if (this.modelValue?.min === undefined)
return '';
return this.modelValue.min;
},
set(v) {
if (v === '') {
if (this.max === '')
this.$emit('update:modelValue', undefined);
else
this.$emit('update:modelValue', { max: this.max });
} else if (v == this.max) {
this.$emit('update:modelValue', v);
} else {
if (this.max === '')
this.$emit('update:modelValue', { min: v });
else
this.$emit('update:modelValue', {
min: v,
max: this.max,
});
}
},
},
max: {
get() {
if (Number.isInteger(this.modelValue))
return this.modelValue;
if (this.modelValue?.max === undefined)
return '';
return this.modelValue.max;
},
set(v) {
if (v === '') {
if (this.min === '')
this.$emit('update:modelValue', undefined);
else
this.$emit('update:modelValue', { min: this.min });
} else if (v == this.min) {
this.$emit('update:modelValue', v);
} else {
if (this.min === '')
this.$emit('update:modelValue', { max: v });
else
this.$emit('update:modelValue', {
min: this.min,
max: v,
});
}
},
},
},
template: /* html */`
<div class="widgets-admin-edit-size input-group">
<span class="input-group-text"><slot /></span>
<form-input
v-model="min"
type="text"
placeholder="1"
:disabled="disabled"
input-group
/>
<span class="input-group-text">-</span>
<form-input
v-model="max"
type="text"
placeholder="&infin;"
:disabled="disabled"
input-group
/>
</div>
`
}
@@ -0,0 +1,173 @@
import { CoreFilterCmpt } from "../../filter/Filter.js";
import BsConfirm from "../../Bootstrap/Confirm.js";
import ApiDashboardWidget from "../../../api/factory/dashboard/widget.js";
export default {
name: 'WidgetsAdminList',
components: {
CoreFilterCmpt,
},
inject: {
imageSrc: "imageSrc",
},
props: {
unsavedProgress: Boolean,
},
emit: [
"new",
"select",
],
data() {
return {
tabulatorOptions: {
ajaxURL: 'dummy',
ajaxRequestFunc: () => this.$api.call(ApiDashboardWidget.listAllOriginal()),
ajaxResponse: (url, params, response) => response?.data,
columns: [
{
field: 'setup.icon',
title: 'Icon',
titlePhrase: 'dashboard/widget_icon',
formatter: (cell, formatterParams) => {
const img = document.createElement('img');
img.src = this.imageSrc(cell.getData().setup?.icon);
if (formatterParams?.height)
img.style.height = formatterParams.height;
return img;
},
formatterParams: {
height: '1.5em',
},
hozAlign: 'center'
},
{
field: 'setup.name',
title: 'Name',
titlePhrase: 'global/name',
},
{
field: 'widget_kurzbz',
title: 'Kurzbz',
titlePhrase: 'dashboard/widget_kurzbz',
},
{
field: 'berechtigung_kurzbz',
title: 'Berechtigung',
titlePhrase: 'global/permission',
},
],
locale: true,
selectableRows: 1,
index: 'widget_id',
persistenceID: 'widgets-admin-list',
height: '100%',
selectableRowsCheck: this.selectableCheck,
},
tabulatorEvents: [
{
event: 'rowSelected',
handler: this.selectWidget,
},
{
event: 'dataLoading',
handler: () => {
// save current
const selected = this.$refs.table.tabulator.getSelectedRows();
if (selected.length)
this.lastSelected = selected[0].getData().widget_id;
},
},
{
event: 'dataProcessing',
handler: () => {
this.adding = true;
},
},
{
event: 'dataProcessed',
handler: () => {
// reselect current
this.adding = false;
if (this.lastSelected !== null) {
this.$refs.table.tabulator.selectRow(this.lastSelected);
this.lastSelected = null;
}
},
},
],
lastSelected: null,
adding: false,
};
},
methods: {
selectRow(row) {
// deselect
row.getTable().getSelectedRows().forEach(r => r.deselect());
// select
row.select();
},
updateOrAddDataAndSelect(data) {
this.$refs.table.tabulator
.updateOrAddData([ data ])
.then(res => this.selectRow(res[0]))
.catch(this.$fhcAlert.handleSystemError);
},
selectableCheck(row) {
if (this.adding)
return true;
if (!this.unsavedProgress)
return true;
BsConfirm
.popup(this.$p.t('dashboard/confirm_unsaved_progress'))
.then(() => this.selectRow(row))
.catch(() => {});
return false;
},
newWidget() {
if (this.unsavedProgress) {
BsConfirm
.popup(this.$p.t('dashboard/confirm_unsaved_progress'))
.then(() => this.$emit('new'))
.catch(() => {});
} else {
this.$emit('new');
}
},
selectWidget(data) {
if (this.lastSelected === null)
this.$emit('select', data.getData());
if (!data.isSelected()) {
/** NOTE(chris): There is a bug in Tabulator where the selected
* object gets duplicated and doesnt't match in comparisons.
* This results in two visible selected entries, but only one of
* them is really selected. This seems to happen if a newly
* added row is selected the second time.
*/
this.selectRow(data);
}
},
},
template: /* html */`
<div class="widgets-admin-list h-100 d-flex flex-column">
<core-filter-cmpt
ref="table"
:tabulator-options="tabulatorOptions"
:tabulator-events="tabulatorEvents"
:side-menu="false"
table-only
reload
new-btn-show
:new-btn-label="$p.t('dashboard/widget')"
@click:new="newWidget"
>
</core-filter-cmpt>
</div>
`
}
@@ -0,0 +1,92 @@
import BsModal from '../../Bootstrap/Modal.js';
import CoreForm from '../../Form/Form.js';
import FormInput from '../../Form/Input.js';
import ApiWidget from '../../../api/factory/dashboard/widget.js';
export default {
name: 'WidgetsAdminNew',
components: {
BsModal,
CoreForm,
FormInput,
},
emit: [
"create",
],
data() {
return {
generators: [],
result: { setup: {} },
};
},
methods: {
show() {
this.result = {
widget_kurzbz: '',
setup: {
generator: null,
},
};
this.$refs.form.clearValidation();
this.$refs.modal.show();
},
finish() {
this.$refs.form
.call(ApiWidget.create(this.result))
.then(res => {
this.$emit('create', res.data);
this.$refs.modal.hide();
})
.catch(this.$fhcAlert.handleSystemError);
},
},
created() {
this.$api
.call(ApiWidget.generators())
.then(res => {
this.generators = res.data
})
.catch(this.$fhcAlert.handleSystemError);
},
template: /* html */`
<core-form
class="widgets-admin-new"
ref="form"
@submit.prevent="finish"
>
<bs-modal ref="modal">
<template #default>
<form-input
v-model="result.setup.generator"
type="select"
:label="$p.t('dashboard/generator')"
class="mb-3"
>
<option v-for="(generator, name) in generators" :key="name" :value="generator">
{{ generator }}
</option>
</form-input>
<form-input
v-model="result.widget_kurzbz"
type="text"
name="widget_kurzbz"
:label="$p.t('dashboard/widget_kurzbz') + ':'"
/>
</template>
<template #footer>
<button type="submit" class="btn btn-primary">
{{ $p.t('ui/ok') }}
</button>
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
{{ $p.t('ui/cancel') }}
</button>
</template>
</bs-modal>
</core-form>
`
}
@@ -0,0 +1,168 @@
import EditSetup from '../../../Dashboard/WidgetAdmin/Edit/Setup.js';
import EditPermission from '../../../Dashboard/WidgetAdmin/Edit/Permission.js';
import ReportPicker from '../../../Dashboard/WidgetAdmin/Edit/Report/Picker.js';
import ReportVars from '../../../Dashboard/WidgetAdmin/Edit/Report/Vars.js';
import ReportAxis from '../../../Dashboard/WidgetAdmin/Edit/Report/Axis.js';
import ReportCharts from '../../../Dashboard/WidgetAdmin/Edit/Report/Charts.js';
import FormInput from '../../../Form/Input.js';
import BsConfirm from "../../../Bootstrap/Confirm.js";
import ApiReport from '../../../../api/factory/report.js';
export default {
name: "WidgetsGeneratorReportGraph",
components: {
EditSetup,
EditPermission,
ReportPicker,
ReportVars,
ReportAxis,
ReportCharts,
FormInput,
},
props: {
modelValue: Object,
},
emits: [
"update:modelValue",
],
data() {
return {
details: null,
};
},
methods: {
getTitleFromPicker() {
const statistik_kurzbz = this.modelValue.arguments.statistik_kurzbz;
let currentItem = this.$refs.picker.allItems
.find(item => item.statistik_kurzbz == statistik_kurzbz);
return currentItem?.bezeichnung || statistik_kurzbz;
},
attemptReportChange(value) {
if (this.modelValue.arguments.statistik_kurzbz == value)
return;
const hasVars = Object.entries(this.modelValue.arguments.vars).length;
const hasGraphoptions = this.modelValue.arguments.charts.length;
const hasDefaultTitle = this.modelValue.arguments.title == this.getTitleFromPicker();
if (hasVars || hasGraphoptions) {
BsConfirm
.popup(this.$p.t('dashboard/widget_report_statistik_change'))
.then(() => {
this.modelValue.arguments.statistik_kurzbz = value;
this.modelValue.arguments.charts = [];
if (hasDefaultTitle || this.modelValue.arguments.title == '')
this.modelValue.arguments.title = this.getTitleFromPicker();
this.loadDetails();
})
.catch(() => {
this.$refs.picker.initSelectedValue();
});
} else {
this.modelValue.arguments.statistik_kurzbz = value;
if (hasDefaultTitle || this.modelValue.arguments.title == '')
this.modelValue.arguments.title = this.getTitleFromPicker();
this.loadDetails();
}
},
loadDetails() {
this.details = null;
this.$api
.call(ApiReport.vars(this.modelValue.arguments.statistik_kurzbz))
.then(result => {
const allowedKeys = result.data.map(detail => detail.kurzbz);
if (!this.modelValue.arguments.vars)
this.modelValue.arguments.vars = {};
else
for (var key in this.modelValue.arguments.vars) {
if (!allowedKeys.includes(key))
delete this.modelValue.arguments.vars[key];
};
result.data.forEach(detail => {
if (!this.modelValue.arguments.vars[detail.kurzbz])
this.modelValue.arguments.vars[detail.kurzbz] = { type: 'fix' };
});
if (result.meta?.berechtigung_kurzbz)
this.modelValue.berechtigung_kurzbz = result.meta.berechtigung_kurzbz;
this.details = result.data;
})
.catch(this.$fhcAlert.handleSystemErrors)
},
},
created() {
if (this.modelValue.arguments.statistik_kurzbz)
this.loadDetails(this.modelValue.arguments.statistik_kurzbz);
if (!this.modelValue.arguments.vars) {
this.$emit('update:modelValue', {
...this.modelValue,
setup: {
...this.modelValue.setup,
icon: '/public/images/widgets/Graph_Widget.svg',
name: '',
hideFooter: false,
},
arguments: {
title: '',
statistik_kurzbz: '',
vars: {},
xAxis: {
field: '',
title: '',
},
charts: [],
},
berechtigung_kurzbz: null,
});
}
},
template: /*html*/ `
<div class="widgets-generator-report-graph">
<edit-setup
v-model="modelValue.setup"
class="border-bottom mb-3"
edit-name
edit-size
edit-hide-footer
/>
<form-input
v-model="modelValue.arguments.title"
:label="$p.t('global/titel')"
class="mb-3"
/>
<report-picker
ref="picker"
:model-value="modelValue.arguments.statistik_kurzbz || ''"
class="mb-3"
@update:model-value="attemptReportChange"
/>
<template v-if="details">
<report-vars
v-model="modelValue.arguments.vars"
:details="details"
class="mb-3"
/>
<report-axis
v-model="modelValue.arguments.xAxis"
class="mb-3"
/>
<report-charts
v-model="modelValue.arguments.charts"
class="mb-3"
/>
</template>
<div
v-else-if="modelValue?.arguments?.statistik_kurzbz"
class="placeholder-glow mb-3"
>
<span class="placeholder col-6"></span>
</div>
<edit-permission v-model="modelValue.berechtigung_kurzbz" disabled />
</div>
`,
};
@@ -0,0 +1,138 @@
import EditSetup from '../../../Dashboard/WidgetAdmin/Edit/Setup.js';
import EditPermission from '../../../Dashboard/WidgetAdmin/Edit/Permission.js';
import ReportPicker from '../../../Dashboard/WidgetAdmin/Edit/Report/Picker.js';
import ReportVars from '../../../Dashboard/WidgetAdmin/Edit/Report/Vars.js';
import ReportAggregators from '../../../Dashboard/WidgetAdmin/Edit/Report/Aggregators.js';
import BsConfirm from "../../../Bootstrap/Confirm.js";
import ApiReport from '../../../../api/factory/report.js';
export default {
name: "WidgetsGeneratorReportKpi",
components: {
EditSetup,
EditPermission,
ReportPicker,
ReportVars,
ReportAggregators,
},
props: {
modelValue: Object,
},
emits: [
"update:modelValue",
],
data() {
return {
details: null,
};
},
methods: {
attemptReportChange(value) {
if (this.modelValue.arguments.statistik_kurzbz == value)
return;
const hasVars = Object.entries(this.modelValue.arguments.vars).length;
const hasAggregators = this.modelValue.arguments.aggregators.length;
if (hasVars || hasAggregators) {
BsConfirm
.popup(this.$p.t('dashboard/widget_report_statistik_change'))
.then(() => {
this.modelValue.arguments.statistik_kurzbz = value;
this.modelValue.arguments.aggregators = [];
this.loadDetails();
})
.catch(() => {
this.$refs.picker.initSelectedValue();
});
} else {
this.modelValue.arguments.statistik_kurzbz = value;
this.loadDetails();
}
},
loadDetails() {
this.details = null;
this.$api
.call(ApiReport.vars(this.modelValue.arguments.statistik_kurzbz))
.then(result => {
const allowedKeys = result.data.map(detail => detail.kurzbz);
if (!this.modelValue.arguments.vars)
this.modelValue.arguments.vars = {};
else
for (var key in this.modelValue.arguments.vars) {
if (!allowedKeys.includes(key))
delete this.modelValue.arguments.vars[key];
};
result.data.forEach(detail => {
if (!this.modelValue.arguments.vars[detail.kurzbz])
this.modelValue.arguments.vars[detail.kurzbz] = { type: 'fix' };
});
if (result.meta?.berechtigung_kurzbz)
this.modelValue.berechtigung_kurzbz = result.meta.berechtigung_kurzbz;
this.details = result.data;
})
.catch(this.$fhcAlert.handleSystemErrors)
},
},
created() {
if (this.modelValue.arguments.statistik_kurzbz)
this.loadDetails(this.modelValue.arguments.statistik_kurzbz);
if (!this.modelValue.arguments.vars) {
this.$emit('update:modelValue', {
...this.modelValue,
setup: {
...this.modelValue.setup,
icon: '/public/images/widgets/KPI_Widget.svg',
name: '',
width: 1,
height: 1,
hideFooter: false,
},
arguments: {
statistik_kurzbz: '',
vars: {},
aggregators: [],
},
berechtigung_kurzbz: null,
});
}
},
template: /*html*/ `
<div class="widgets-generator-report-kpi">
<edit-setup
v-model="modelValue.setup"
class="border-bottom mb-3"
edit-name
/>
<report-picker
ref="picker"
:model-value="modelValue.arguments.statistik_kurzbz || ''"
class="mb-3"
@update:model-value="attemptReportChange"
/>
<template v-if="details">
<report-vars
v-model="modelValue.arguments.vars"
:details="details"
class="mb-3"
/>
<report-aggregators
v-model="modelValue.arguments.aggregators"
:details="details"
class="mb-3"
/>
</template>
<div
v-else-if="modelValue?.arguments?.statistik_kurzbz"
class="placeholder-glow mb-3"
>
<span class="placeholder col-6"></span>
</div>
<edit-permission v-model="modelValue.berechtigung_kurzbz" disabled />
</div>
`,
};
@@ -0,0 +1,21 @@
import AbstractWidget from './Abstract.js';
/**
* Fallback screen rendered in place of a widget when the current user is missing
* the permission (berechtigung_kurzbz) linked to that widget. Used for widgets
* that are already on a user's dashboard but whose required permission the user
* no longer holds.
*/
export default {
mixins: [
AbstractWidget
],
created() {
this.$emit('setConfig', false);
},
template: /*html*/`
<div class="dashboard-widget-missing-permission d-flex flex-column justify-content-center align-items-center text-center h-100 p-3 text-body-secondary">
<i class="fa-solid fa-lock fa-2x mb-2" aria-hidden="true"></i>
<p class="mb-0">{{ $p.t('dashboard/widget_missing_permission') }}</p>
</div>`
}
@@ -0,0 +1,85 @@
import AbstractWidget from '../../Abstract.js';
import VarsVar from '../../../Dashboard/WidgetAdmin/Edit/Report/Vars/Var.js';
import FormInput from '../../../Form/Input.js';
export default {
name: "WidgetsReportGraphSetup",
components:{
VarsVar,
FormInput,
},
mixins: [ AbstractWidget ],
computed: {
filter: {
get() {
if (this.config.filter !== undefined)
return this.config.filter;
if (this.config.charts.length > 1)
return Array.from(this.config.charts.keys());
return [];
},
set(v) {
if (v.length == this.config.charts.length)
v = undefined;
this.config.filter = v;
}
},
customVars() {
const uservars = this.config.uservars || {};
return Object.entries(this.config.vars).reduce((res, [key, variable]) => {
if (variable.type == 'user') {
res[key] = {
modelValue: uservars[key] || {},
detail: variable.detail,
};
} else if (this.sharedData && this.sharedData[key]) {
res[key] = {
modelValue: uservars[key] || {},
detail: this.sharedData[key],
};
}
return res;
}, {});
},
hasCustomVars() {
return Object.keys(this.customVars).length;
},
},
methods: {
updateUserVar(key, { value }) {
if (!this.config.uservars)
this.config.uservars = {};
this.config.uservars[key] = { value };
},
},
template: /*html*/ `
<div class="widgets-report-config-graph">
<div v-if="hasCustomVars" :class="config.charts.length > 1 ? 'mb-3' : ''">
<label class="form-label">{{ $p.t('dashboard/widget_report_vars') }}</label>
<vars-var
v-for="(variable, key) in customVars"
:key="key"
v-bind="variable"
no-type
@update:model-value="updateUserVar(key, $event)"
/>
</div>
<template v-if="config.charts.length > 1">
<label class="form-label">{{ $p.t('dashboard/widget_report_graph_charts') }}</label>
<div v-for="(chart, i) in config.charts">
<form-input
v-model="filter"
type="checkbox"
:label="chart.label || chart.field"
name="filter"
:value="i"
:disabled="filter.length == 1 && filter.indexOf(i) != -1"
/>
</div>
</template>
</div>
`,
};
@@ -0,0 +1,86 @@
import AbstractWidget from '../../Abstract.js';
import VarsVar from '../../../Dashboard/WidgetAdmin/Edit/Report/Vars/Var.js';
import FormInput from '../../../Form/Input.js';
export default {
name: "WidgetsReportKpiSetup",
components:{
VarsVar,
FormInput,
},
mixins: [ AbstractWidget ],
computed: {
aggregator: {
get() {
if (this.config.aggregator !== undefined)
return this.config.aggregator;
if (this.config.aggregators.length == 1)
return this.config.aggregators[0];
if (this.config.aggregators.length > 1) {
const def = this.config.aggregators.findIndex(agg => agg.default);
if (def > 0)
return def;
}
return 0;
},
set(v) {
this.config.aggregator = v;
}
},
customVars() {
const uservars = this.config.uservars || {};
return Object.entries(this.config.vars).reduce((res, [key, variable]) => {
if (variable.type == 'user') {
res[key] = {
modelValue: uservars[key] || {},
detail: variable.detail,
};
} else if (this.sharedData && this.sharedData[key]) {
res[key] = {
modelValue: uservars[key] || {},
detail: this.sharedData[key],
};
}
return res;
}, {});
},
hasCustomVars() {
return Object.keys(this.customVars).length;
},
},
methods: {
updateUserVar(key, { value }) {
if (!this.config.uservars)
this.config.uservars = {};
this.config.uservars[key] = { value };
},
},
template: /*html*/ `
<div class="widgets-report-config-kpi">
<div v-if="hasCustomVars" :class="config.aggregators.length > 1 ? 'mb-3' : ''">
<label class="form-label">{{ $p.t('dashboard/widget_report_vars') }}</label>
<vars-var
v-for="(variable, key) in customVars"
:key="key"
v-bind="variable"
no-type
@update:model-value="updateUserVar(key, $event)"
/>
</div>
<form-input
v-if="config.aggregators.length > 1"
type="select"
:label="$p.t('dashboard/widget_report_kpi_aggregator')"
v-model="aggregator"
>
<option v-for="(aggregator, i) in config.aggregators" :key="i" :value="i">
{{ aggregator.label }}
</option>
</form-input>
</div>
`,
};
@@ -0,0 +1,47 @@
export default {
name: "WidgetsReportError",
props: {
errors: {
type: [ Object, Boolean ],
required: true
},
},
computed: {
myClass() {
if (this.errors !== true)
return 'w-100 overflow-scroll';
return [
'alert',
'alert-danger',
'm-0',
'h-100',
'w-100',
'border-0',
'rounded-0',
'd-flex',
'justify-content-center',
'align-items-center'
];
},
},
template: /*html*/ `
<div class="widgets-report-error" :class="myClass">
<template v-if="errors === true">
{{ $p.t('ui/errorConfigFehlt') }}
</template>
<template v-else>
<template v-for="error in errors" :key="error">
<div v-if="error.message" class="alert alert-danger m-1">
{{ error.message }}
</div>
<template v-else-if="error.messages">
<div v-for="msg in error.messages" :key="msg" class="alert alert-danger m-1">
{{ msg }}
</div>
</template>
</template>
</template>
</div>
`,
};
@@ -0,0 +1,253 @@
import AbstractWidget from '../Abstract.js';
import ReportError from './Error/Error.js';
import ConfigGraph from './Config/Graph.js';
import FhcChart from '../../Chart/FhcChart.js';
import { useCalculatedVars } from '../../../composables/DashboardWidget/Report/CalculatedVars.js';
import ApiReport from '../../../api/factory/report.js';
export default {
name: "WidgetsReportGraph",
components: {
ReportError,
ConfigGraph,
FhcChart,
},
mixins: [ AbstractWidget ],
inject: {
adminMode: {
from: 'adminMode',
default: false
}
},
data() {
return {
calledVars: {},
data: undefined,
hasErrors: false,
};
},
computed: {
filteredCharts() {
if (!this.config.filter)
return this.config.charts;
return this.config.charts.filter((_data, index) => this.config.filter.includes(index));
},
filteredData() {
if (!this.data)
return null;
const title = (this.config.title || '').replace(
/\{\s*([^\}]+)\s*\}/g,
(match, key) => {
if (this.calledVars[key] === undefined)
return match;
if (this.config.vars[key].detail?.options) {
const options = this.config.vars[key].detail.options;
const res = options.find(opt => opt.value == this.calledVars[key]);
if (res?.label)
return res.label;
}
return this.calledVars[key];
}
);
let result = {
title: {
text: title,
},
credits: {
text: "",
href: "https:\/\/fhcomplete.org"
},
chart: {
zoomType: "xy",
type: "line",
animation: true
},
xAxis: {
categories: [],
title: {
text: this.config.xAxis.title || '',
},
},
yAxis: {
title: { text: "" },
},
series: this.filteredCharts.reduce((res, curr) => {
res[curr.field] = {
name: curr.label || curr.field,
data: [],
};
switch (curr.type) {
case "bar":
res[curr.field].type = "column";
break;
case "dotted":
res[curr.field].dashStyle = "dot";
break;
case "dashed":
res[curr.field].dashStyle = "longdash";
break;
}
if (curr.color)
res[curr.field].color = '#' + curr.color.replace(/^#+/, '');
if (curr.text)
res[curr.field].dataLabels = { color: '#' + curr.text.replace(/^#+/, '') };
return res;
}, {}),
};
const chartNames = Object.keys(result.series);
result = this.data.reduce((res, curr) => {
const xValue = curr[this.config.xAxis.field];
res.xAxis.categories.push(xValue);
chartNames.forEach(
chart => res.series[chart].data.push([xValue, Number(curr[chart])])
);
return res;
}, result);
result.series = Object.values(result.series);
return result;
},
chartSetup() {
if (this.adminMode)
return '?';
if (this.filteredData !== null)
return this.filteredData;
return false;
}
},
watch: {
config: {
handler() { this.fetchData(); },
immediate: true,
deep: true,
},
width(o, n) {
if (o != n && this.$refs.chart)
this.$refs.chart.reflow();
},
height(o, n) {
if (o != n && this.$refs.chart)
this.$refs.chart.reflow();
},
},
methods: {
async fetchData() {
if (this.configMode)
return;
this.hasErrors = false;
let vars = {};
for (var key in this.config.vars) {
vars[key] = await this.getValueForVar(this.config.vars[key]);
if (this.config.vars[key].type == 'user') {
if (!this.config.uservars || !this.config.uservars[key])
delete vars[key];
else
vars[key] = this.config.uservars[key].value;
} else if (this.config.vars[key].type.substr(0, 5) == 'calc:') {
const requiresArray = this.config.vars[key].detail.multiple ? true : false;
const isArray = Array.isArray(vars[key]);
if (requiresArray && !isArray) {
vars[key] = [ vars[key] ];
} else if (!requiresArray && isArray) {
const isSelect = this.config.vars[key].detail.type == 'select';
let val = undefined;
if (this.config.uservars)
val = this.config.uservars[key]?.value;
if (val && !vars[key].some(v1 => v1 == val)) {
val = undefined;
delete this.config.uservars[key].value;
}
if (isSelect && val && !this.config.vars[key].detail.options.some(option => option.value == val)) {
val = undefined;
delete this.config.uservars[key].value;
}
if (!val)
val = vars[key].find(Boolean); // get first element
let detail = { ...this.config.vars[key].detail, type: 'select' };
if (this.config.vars[key].detail.type == 'select') {
detail.options = this.config.vars[key].detail.options
.filter(option => vars[key].some(v => v == option.value));
} else {
detail.options = vars[key].map(value => ({ label: value, value }));
}
const sharedData = this.sharedData || {};
sharedData[key] = detail;
this.$emit('update:sharedData', sharedData);
this.$emit('setConfig', Vue.markRaw(ConfigGraph));
vars[key] = val;
}
}
}
this.calledVars = vars;
try {
const result = await this.$api.call(
ApiReport.get(this.config.statistik_kurzbz, vars),
{ errorHandling: false }
);
this.data = result.data;
} catch(error) {
const hasEmptyUserValues = Object.entries(this.config.vars)
.some(([key, v]) => {
if (v.type != 'user')
return false;
if (!this.config.uservars)
return true;
if (!this.config.uservars[key])
return true;
return false;
});
if (hasEmptyUserValues)
return this.hasErrors = true;
if (error.response.data.errors)
return this.hasErrors = error.response.data.errors;
this.$fhcAlert.handleSystemError(error);
}
},
},
setup() {
const { getValueForVar } = useCalculatedVars();
return {
getValueForVar
};
},
mounted() {
if (!this.adminMode) {
if (Object.values(this.config.vars).some(v => v.type == 'user'))
this.$emit('setConfig', Vue.markRaw(ConfigGraph));
else if (this.config.charts.length > 1)
this.$emit('setConfig', Vue.markRaw(ConfigGraph));
}
},
template: /*html*/ `
<div class="widgets-report-graph w-100 h-100 d-flex flex-column justify-content-center align-items-center">
<report-error v-if="hasErrors" :errors="hasErrors" />
<template v-else-if="chartSetup !== false">
<fhc-chart ref="chart" :chart-options="chartSetup" />
</template>
<i v-else class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
`,
};
@@ -0,0 +1,207 @@
import AbstractWidget from '../Abstract.js';
import ReportError from './Error/Error.js';
import ConfigKpi from './Config/Kpi.js';
import { useCalculatedVars } from '../../../composables/DashboardWidget/Report/CalculatedVars.js';
import ApiReport from '../../../api/factory/report.js';
export default {
name: "WidgetsReportKpi",
components: {
ReportError,
ConfigKpi,
},
mixins: [ AbstractWidget ],
inject: {
adminMode: {
from: 'adminMode',
default: false
}
},
data() {
return {
calledVars: {},
data: undefined,
hasErrors: false,
};
},
computed: {
activeLabel() {
if (!this.activeAggregator.label)
return '';
return this.activeAggregator.label.replace(
/\{\s*([^\}]+)\s*\}/g,
(match, key) => {
if (this.calledVars[key] === undefined)
return match;
if (this.config.vars[key].detail?.options) {
const options = this.config.vars[key].detail.options;
const res = options.find(opt => opt.value == this.calledVars[key]);
if (res?.label)
return res.label;
}
return this.calledVars[key];
}
);
},
activeAggregator() {
if (this.config.aggregators.length == 1)
return this.config.aggregators[0];
if (this.config.aggregator !== undefined)
return this.config.aggregators[this.config.aggregator];
return this.config.aggregators.find(agg => agg.default);
},
filteredData() {
if (!this.data)
return null;
if (!this.activeAggregator)
return null;
if (this.activeAggregator.type == 'count')
return this.data.length;
if (this.activeAggregator.type == 'sum') {
const fields = this.activeAggregator.fields.split(',');
return this.data.reduce(
(res, curr) => res + fields.reduce(
(sum, key) => sum + (curr[key] || 0),
0
),
0
);
}
},
kpi() {
if (this.adminMode)
return '?';
if (this.filteredData !== null)
return this.filteredData;
return false;
}
},
watch: {
config: {
handler() { this.fetchData(); },
immediate: true,
deep: true,
}
},
methods: {
async fetchData() {
if (this.configMode)
return;
this.hasErrors = false;
let vars = {};
for (var key in this.config.vars) {
vars[key] = await this.getValueForVar(this.config.vars[key]);
if (this.config.vars[key].type == 'user') {
if (!this.config.uservars || !this.config.uservars[key])
delete vars[key];
else
vars[key] = this.config.uservars[key].value;
} else if (this.config.vars[key].type.substr(0, 5) == 'calc:') {
const requiresArray = this.config.vars[key].detail.multiple ? true : false;
const isArray = Array.isArray(vars[key]);
if (requiresArray && !isArray) {
vars[key] = [ vars[key] ];
} else if (!requiresArray && isArray) {
const isSelect = this.config.vars[key].detail.type == 'select';
let val = undefined;
if (this.config.uservars)
val = this.config.uservars[key]?.value;
if (val && !vars[key].some(v1 => v1 == val)) {
val = undefined;
delete this.config.uservars[key].value;
}
if (isSelect && val && !this.config.vars[key].detail.options.some(option => option.value == val)) {
val = undefined;
delete this.config.uservars[key].value;
}
if (!val)
val = vars[key].find(Boolean); // get first element
let detail = { ...this.config.vars[key].detail, type: 'select' };
if (this.config.vars[key].detail.type == 'select') {
detail.options = this.config.vars[key].detail.options
.filter(option => vars[key].some(v => v == option.value));
} else {
detail.options = vars[key].map(value => ({ label: value, value }));
}
const sharedData = this.sharedData || {};
sharedData[key] = detail;
this.$emit('update:sharedData', sharedData);
this.$emit('setConfig', Vue.markRaw(ConfigKpi));
vars[key] = val;
}
}
}
this.calledVars = vars;
try {
const result = await this.$api.call(
ApiReport.get(this.config.statistik_kurzbz, vars),
{ errorHandling: false }
);
this.data = result.data;
} catch(error) {
if (this.activeAggregator === undefined)
return this.hasErrors = true;
const hasEmptyUserValues = Object.entries(this.config.vars)
.some(([key, v]) => {
if (v.type != 'user')
return false;
if (!this.config.uservars)
return true;
if (!this.config.uservars[key])
return true;
return false;
});
if (hasEmptyUserValues)
return this.hasErrors = true;
if (error.response.data.errors)
return this.hasErrors = error.response.data.errors;
this.$fhcAlert.handleSystemError(error);
}
},
},
setup() {
const { getValueForVar } = useCalculatedVars();
return {
getValueForVar
};
},
mounted() {
if (!this.adminMode) {
if (Object.values(this.config.vars).some(v => v.type == 'user'))
this.$emit('setConfig', Vue.markRaw(ConfigKpi));
else if (this.config.aggregators.length > 1)
this.$emit('setConfig', Vue.markRaw(ConfigKpi));
}
},
template: /*html*/ `
<div class="widgets-report-kpi w-100 h-100 d-flex flex-column justify-content-center align-items-center">
<report-error v-if="hasErrors" :errors="hasErrors" />
<template v-else-if="kpi !== false">
<div class="h1 text-center">{{ kpi }}</div>
<small>{{ activeLabel }}</small>
</template>
<i v-else class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
`,
};
+29 -3
View File
@@ -27,6 +27,7 @@ export default {
name: String,
containerClass: [String, Array, Object],
label: String,
labelClass: [String, Array, Object],
// NOTE(chris): remove these from $attrs array to prevent doubled event listeners
onInput: [Array, Function],
'onUpdate:modelValue': [Array, Function],
@@ -63,7 +64,7 @@ export default {
if (this.noAutoClass)
return this.acc;
const acc = {...this.acc};
const acc = { ...this.acc };
if (this.inputGroup)
acc['input-group-item'] = true;
@@ -78,6 +79,31 @@ export default {
}
return acc;
},
alc() {
if (!this.labelClass || !this.label)
return {};
if (typeof this.labelClass === 'string' || this.labelClass instanceof String)
return this.labelClass.split(' ').reduce((a,c) => {a[c] = true; return a}, {});
if (Array.isArray(this.labelClass))
return this.labelClass.reduce((a,c) => {a[c] = true; return a}, {});
return this.labelClass;
},
autoLabelClass() {
if (!this.label)
return {};
if (this.noAutoClass)
return this.alc;
const alc = { ...this.alc };
if (this.lcType == 'radio' || this.lcType == 'checkbox')
alc['form-check-label'] = true;
else
alc['form-label'] = true;
return alc;
},
lcType() {
if (!this.type)
return 'text';
@@ -243,7 +269,7 @@ export default {
},
template: `
<component :is="!hasContainer ? 'FhcFragment' : 'div'" class="position-relative" :class="autoContainerClass">
<label v-if="label && lcType != 'radio' && lcType != 'checkbox'" :class="!noAutoClass && 'form-label'" :for="idCmp">{{label}}</label>
<label v-if="label && lcType != 'radio' && lcType != 'checkbox'" :class="autoLabelClass" :for="idCmp">{{label}}</label>
<input v-if="tag == 'input'" :type="lcType" ref="input" v-model="modelValueCmp" v-bind="$attrs" :id="idCmp" :name="name" :class="validationClass" :modelValue="undefined" @input="$emit('input', $event)">
<textarea v-else-if="tag == 'textarea'" ref="input" v-model="modelValueCmp" v-bind="$attrs" :id="idCmp" :name="name" :class="validationClass" :modelValue="undefined" @input="$emit('input', $event)"></textarea>
<select v-else-if="tag == 'select'" ref="input" v-model="modelValueCmp" v-bind="$attrs" :id="idCmp" :name="name" :class="validationClass" :modelValue="undefined" @input="$emit('input', $event)">
@@ -323,7 +349,7 @@ export default {
>
<slot></slot>
</component>
<label v-if="label && (lcType == 'radio' || lcType == 'checkbox')" :for="idCmp" :class="!noAutoClass && 'form-check-label'" v-html="label"></label>
<label v-if="label && (lcType == 'radio' || lcType == 'checkbox')" :for="idCmp" :class="autoLabelClass" v-html="label"></label>
<div v-if="valid !== undefined && feedback.length && !noFeedback" :class="feedbackClass">
<template v-for="(msg, i) in feedback" :key="i">
<hr v-if="i" class="m-0">
@@ -4,7 +4,11 @@ export default {
defaultRatio: {
type: Array,
default: () => [50, 50]
}
},
useDivHeight: {
type: Boolean,
default: false,
},
},
data: function() {
return {
@@ -61,9 +65,16 @@ export default {
},
methods: {
calcHeights: function() {
var windowheight = window.innerHeight;
var windowheight = 0;
if (this.useDivHeight) {
windowheight = this.$refs.verticalsplit.offsetHeight - this.$refs.vsplitter.offsetHeight;
this.selfOffsetTop = 0;
} else {
windowheight = window.innerHeight;
this.selfOffsetTop = this.$refs.verticalsplit.offsetTop;
}
var oldavailHeight = this.availHeight;
this.selfOffsetTop = this.$refs.verticalsplit.offsetTop;
this.availHeight = windowheight - this.selfOffsetTop - this.$refs.vsplitter.offsetHeight;
if( (this.topheight === 0 && this.bottomheight === 0) || oldavailHeight === 0 ) {
@@ -0,0 +1,69 @@
/**
* 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/>.
*/
import CurrentStudienjahr from './CalculatedVars/CurrentStudienjahr.js';
import CalcDate from './CalculatedVars/CalcDate.js';
import UserStgs from './CalculatedVars/UserStgs.js';
export function useCalculatedVars() {
let calculations = {
CurrentStudienjahr,
CalcDate,
UserStgs,
};
const components = Vue.ref({});
const options = Vue.ref([]);
const $p = Vue.inject('$p');
const $api = Vue.inject('$api');
const $fhcAlert = Vue.inject('$fhcAlert');
for (var key in calculations) {
let label = calculations[key].label;
if (Array.isArray(label)) {
label = $p.t(label);
}
options.value.push({
value: key,
label,
});
if (calculations[key].component) {
components.value[key] = Vue.markRaw(calculations[key].component);
}
}
async function getValueForVar(variable) {
if (variable.type.match(/^calc:/)) {
const instructions = variable.type.split(':');
if (calculations[instructions[1]]?.calculate)
return calculations[instructions[1]].calculate.bind({
$p,
$api,
$fhcAlert,
})(instructions);
}
return variable?.value;
}
return {
components,
options,
getValueForVar,
};
}
@@ -0,0 +1,32 @@
/**
* 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/>.
*/
import CalcDate from '../../../../components/Dashboard/WidgetAdmin/Edit/Report/Vars/Calc/Date.js';
export default {
label: [ 'dashboard', 'widget_report_vars_calc_date' ],
component: CalcDate,
async calculate(instructions) {
let duration = instructions[2] || 'P';
if (duration[0] == '-')
duration = luxon.Duration.fromISO(duration.substr(1)).negate();
else
duration = luxon.Duration.fromISO(duration);
return luxon.DateTime.now().plus(duration);
},
}
@@ -0,0 +1,31 @@
/**
* 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/>.
*/
import ApiStudienjahr from '../../../../api/factory/studienjahr.js';
export default {
label: [ 'dashboard', 'widget_report_vars_calc_currentstudienjahr' ],
async calculate(instructions) {
try {
const result = await this.$api.call(ApiStudienjahr.getAktNext());
return result.data.studienjahr_kurzbz;
} catch(error) {
this.$fhcAlert.handleSystemError(error);
}
return null;
},
}
@@ -0,0 +1,34 @@
/**
* 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/>.
*/
import CalcRight from '../../../../components/Dashboard/WidgetAdmin/Edit/Report/Vars/Calc/Right.js';
import ApiStudiengang from '../../../../api/factory/studiengang.js';
export default {
label: [ 'dashboard', 'widget_report_vars_calc_stgs' ],
component: CalcRight,
async calculate(instructions) {
try {
const result = await this.$api.call(ApiStudiengang.getAllowed(instructions[2]));
return result.data;
} catch(error) {
this.$fhcAlert.handleSystemError(error);
}
return null;
},
}
+1
View File
@@ -28,6 +28,7 @@ const copyObject = function (obj, copyArray) {
const highchartsPlugin = {
install(app, options) {
options = options || {};
function destroyChart() {
if (this.chart) {
+1 -1
View File
@@ -103,7 +103,7 @@ 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/54225_reihungstest_ablauf_fuer_quereinsteiger.php');
require_once('dbupdate_3.4/77757_widgets_berechtigungen.php');
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
echo '<H2>Pruefe Tabellen und Attribute!</H2>';
@@ -0,0 +1,57 @@
<?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.
*
* Beschreibung:
* Dashboard Widgets: Verknüpfung einer (einzelnen) Berechtigung pro Widget.
* Neue Spalte dashboard.tbl_widget.berechtigung_kurzbz mit FK auf
* system.tbl_berechtigung. Ist die Spalte NULL, erfordert das Widget keine
* gesonderte Berechtigung. Benutzer denen die verknüpfte Berechtigung fehlt,
* bekommen das Widget nicht mehr zur Auswahl angezeigt bzw. sehen (falls bereits
* am Dashboard) einen "Fehlende Berechtigung"-Screen.
*/
if (! defined('DB_NAME')) exit('No direct script access allowed');
// Add column dashboard.tbl_widget.berechtigung_kurzbz (FK => system.tbl_berechtigung)
if ($result = @$db->db_query("
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'dashboard'
AND table_name = 'tbl_widget'
AND column_name = 'berechtigung_kurzbz';"))
{
if ($db->db_num_rows($result) == 0)
{
$qry = "
ALTER TABLE dashboard.tbl_widget
ADD COLUMN berechtigung_kurzbz VARCHAR(32) NULL;
ALTER TABLE dashboard.tbl_widget
ADD CONSTRAINT tbl_widget_berechtigung_fk
FOREIGN KEY (berechtigung_kurzbz)
REFERENCES system.tbl_berechtigung (berechtigung_kurzbz)
ON UPDATE CASCADE ON DELETE SET NULL;";
if (!$db->db_query($qry))
{
echo '<strong>dashboard.tbl_widget: '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'dashboard.tbl_widget: Spalte berechtigung_kurzbz (FK system.tbl_berechtigung) hinzugefuegt<br>';
}
}
}
+20 -1
View File
@@ -408,7 +408,7 @@ GRANT SELECT,INSERT,DELETE,UPDATE ON TABLE dashboard.tbl_dashboard_widget TO vil
--
GRANT SELECT ON TABLE dashboard.tbl_widget TO web;
GRANT SELECT,INSERT,UPDATE ON TABLE dashboard.tbl_widget TO vilesci;
GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE dashboard.tbl_widget TO vilesci;
--
@@ -466,3 +466,22 @@ if($result = @$db->db_query("SELECT 1 FROM system.tbl_berechtigung WHERE berecht
}
}
// Add delete privilege for vilesci on tbl_widget
if ($result = @$db->db_query("
SELECT 1
FROM information_schema.role_table_grants
WHERE grantee = 'vilesci'
AND table_name = 'tbl_widget'
AND table_schema = 'dashboard'
AND privilege_type = 'DELETE'
LIMIT 1")) {
if ($db->db_num_rows($result) == 0) {
$qry = "GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE dashboard.tbl_widget TO vilesci;";
if(!$db->db_query($qry)) {
echo '<strong>dashboard.tbl_widget '.$db->db_last_error().'</strong><br>';
} else {
echo 'dashboard.tbl_widget: Added delete privilege for vilesci<br>';
}
}
}
+960
View File
@@ -748,6 +748,26 @@ $phrases = array(
)
)
),
array(
'app' => 'core',
'category' => 'global',
'phrase' => 'label',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Bezeichnung',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Label',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'global',
@@ -1353,6 +1373,46 @@ $phrases = array(
)
)
),
array(
'app' => 'core',
'category' => 'global',
'phrase' => 'permission',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechtigung',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Permission',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'global',
'phrase' => 'permissions',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechtigungen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Permissions',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'global',
@@ -1994,6 +2054,26 @@ $phrases = array(
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'default',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Standard',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Default',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',
@@ -29443,6 +29523,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'profil',
'phrase' => 'fhAusweisStatusNichtAusgegeben',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der FH Ausweis wurde noch nicht ausgegeben.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The FH ID card has not been issued yet.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'profil',
@@ -49600,6 +49700,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_missing_permission',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Ihnen fehlt die erforderliche Berechtigung, um dieses Widget anzuzeigen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'You do not have the permission required to display this widget.',
'description' => '',
'insertvon' => 'system'
)
)
),
// CIS4 phrases from legacy code end
// FHC4 Phrases Abschlusspruefung
array(
@@ -60830,6 +60950,846 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widgets',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Widgets',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Widgets',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Widget',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Widget',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'no_widget_selected',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Bitte ein Widget markieren!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Please select a widget!',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'confirm_unsaved_progress',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Widget wurde geändert! Änderungen verwerfen?',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The widget has been modified! Discard changes?',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_error_delete_native',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Native Widgets können nicht gelöscht werden!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Native widgets can not be deleted!',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_id',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Widget ID',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Widget ID',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_kurzbz',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Widget Kurzbz',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Widget short',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_icon',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Icon',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Icon',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'generator',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Generator',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Generator',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_setup_size',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Größe',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Size',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_setup_width',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Breite',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Width',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_setup_height',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Höhe',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Height',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_setup_hidefooter',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Footer verstecken',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Hide footer',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_statistik',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Statistik',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Statistic',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_statistik_change',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Statistik wurde geändert! Einstellungen für vorherige Statistik verwerfen?',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The Statistic has been changed! Discard settings for the previous Statistic?',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Variablen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Variables',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_var_type_fix',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fester Wert',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Fixed',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_var_type_calc',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechnet',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Calculated',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_var_type_user',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Benutzerdefiniert',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'User defined',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_date',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechnetes Datum',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Calculated date',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_currentstudienjahr',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Aktuelles Studienjahr',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Current academic year',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_stgs',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Studiengang des Users',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Users degree program',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_date',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechnetes Datum',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Calculated date',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_date_placeholder',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Dauer (fügen Sie am Anfang ein \'-\' hinzu, um es abzuziehen) ab dem aktuellen Datum',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Duration (add \'-\' at the beginning to substract) from current date',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_vars_calc_right_placeholder',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Berechtigungen (Komma getrennt)',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Access Rights (comma separated)',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregators',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Aggregatoren',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Aggregators',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregator',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Aggregator',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Aggregator',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregator_type_sum',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Summe',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Sum',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregator_type_count',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeilenanzahl',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Row count',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregator_fields',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Felder',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Fields',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_kpi_aggregator_fields_placeholder',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Felder (Komma getrennt)',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Fields (comma separated)',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_field',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'DB Feld',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'DB Field',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_axis',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'X-Achse',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'X-Axis',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Chart',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Chart',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_charts',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Charts',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Charts',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_layout',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Layout',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Layout',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_type_bar',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Balken',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Bar',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_type_line',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Linie',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Line',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_type_dashed',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Strichlierte Linie',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Dashed line',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_type_dotted',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Gepunktete Linie',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Dotted line',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_bg_color',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Farbe',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Color',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_report_graph_chart_text_color',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Textfarbe',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Text color',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',