Compare commits
35 Commits
bc08a1f35c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60eea92f11 | |||
| 5da7916651 | |||
| 8aadf65cc3 | |||
| ab4f38a360 | |||
| 61dde2925b | |||
| 3aa523870f | |||
| 4d6cc807c9 | |||
| a0698d621e | |||
| 07f9f304a4 | |||
| 9817fde8c4 | |||
| 0bf47247ff | |||
| 54d9194d22 | |||
| 8ff44a407e | |||
| ee966de174 | |||
| 55e6d4c583 | |||
| cf97a52128 | |||
| 833c3718cf | |||
| 952b3e2efc | |||
| 887ac6a4bb | |||
| 0a0196bd89 | |||
| d1b9e89df3 | |||
| 4e8dd8745f | |||
| eb3aa75615 | |||
| 8fa061afac | |||
| 1bd08a72be | |||
| e3b4dbe4ff | |||
| e2837b3288 | |||
| 38272c3b11 | |||
| 197152fb97 | |||
| 743476f64a | |||
| 2e75ff7d59 | |||
| 5c585caeba | |||
| b2a3a23116 | |||
| 0ece15a23b | |||
| c55777c089 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,4 +23,8 @@ docker/
|
||||
*.csv
|
||||
*.png
|
||||
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Filesgallery-Cache (generierte Thumbnails)
|
||||
module/knowledgecenter_update/Plugins/_files/cache/images/*
|
||||
!module/knowledgecenter_update/Plugins/_files/cache/images/.gitkeep
|
||||
11
layout/frontend/b2c/dist/css/global.css
vendored
11
layout/frontend/b2c/dist/css/global.css
vendored
@@ -558,6 +558,10 @@ span.hint {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
nav span.hint {
|
||||
left: -0;
|
||||
}
|
||||
|
||||
.profil_banner.certificate {
|
||||
width: 33.33%;
|
||||
float: left;
|
||||
@@ -9187,6 +9191,13 @@ a:not([class]) {
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
.mobile_navigation_container .hint {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: -5px;
|
||||
left: unset;
|
||||
}
|
||||
|
||||
.mobile_navigation svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
|
||||
@@ -2893,18 +2893,50 @@ function multi_select_permission($collection_id, $type){
|
||||
<script type="text/javascript" src="/plugins/jquery/jquery-3.5.1.min.js"></script>
|
||||
<script type="text/javascript" src="/plugins/Semantic-UI-master/semantic.min.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/Semantic-UI-master/bc-semantic.css?time=<?= filemtime($_SERVER['DOCUMENT_ROOT'] . '/plugins/Semantic-UI-master/bc-semantic.css')?>">
|
||||
<?php
|
||||
$einrichtLinks = [];
|
||||
if ($type == "einricht") {
|
||||
$linkRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
|
||||
while ($lr = mysqli_fetch_assoc($linkRes)) {
|
||||
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<script>
|
||||
|
||||
var $y = jQuery.noConflict();
|
||||
$y( document ).ready(function() {
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($preselect) ?>);
|
||||
var $drop = $y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>');
|
||||
<?php if ($type == "einricht"): ?>
|
||||
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
|
||||
var busy = false;
|
||||
$drop.dropdown({
|
||||
onChange: function(value) {
|
||||
if (busy) return;
|
||||
var current = value ? value.split(',').filter(Boolean) : [];
|
||||
var toAdd = [];
|
||||
current.forEach(function(id) {
|
||||
(einrichtLinks[id] || []).forEach(function(lid) {
|
||||
if (current.indexOf(lid) < 0 && toAdd.indexOf(lid) < 0) toAdd.push(lid);
|
||||
});
|
||||
});
|
||||
if (toAdd.length) {
|
||||
busy = true;
|
||||
$drop.dropdown('set selected', current.concat(toAdd));
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
$drop.dropdown('set selected', <?= json_encode($preselect) ?>);
|
||||
});
|
||||
$("<?php echo "#all_get".$type;?>").click(function() {
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
|
||||
});
|
||||
$("<?php echo "#all_del".$type;?>").click(function() {
|
||||
console.log("delete");
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
|
||||
});
|
||||
</script>
|
||||
<?
|
||||
|
||||
@@ -94,20 +94,24 @@ $forwarding_icon_images = array(
|
||||
<path id="Pfad_280" data-name="Pfad 280" d="M18,11a7,7,0,1,1-7,7,7,7,0,0,1,7-7"/>
|
||||
<path id="Pfad_281" data-name="Pfad 281" d="M18,34A16,16,0,1,1,34,18,16,16,0,0,1,18,34ZM18,4A14,14,0,1,0,32,18,14,14,0,0,0,18,4Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
'
|
||||
</svg>'
|
||||
),
|
||||
"communityboard" => array(
|
||||
"name" => "Communityboard",
|
||||
"image" => '<svg xmlns="https://www.w3.org/2000/svg" width="18.98" height="14.914" viewBox="0 0 18.98 14.914">
|
||||
<path id="comments-o" d="M7.457,1.356a8.244,8.244,0,0,0-3.03.551A5.677,5.677,0,0,0,2.187,3.4a3.01,3.01,0,0,0-.831,2.023A2.886,2.886,0,0,0,1.917,7.1,4.875,4.875,0,0,0,3.5,8.495l1.028.593-.371.89q.36-.212.657-.413l.466-.328.562.106a9.171,9.171,0,0,0,1.621.148,8.244,8.244,0,0,0,3.03-.551,5.677,5.677,0,0,0,2.24-1.494,3.01,3.01,0,0,0,.831-2.023A3.01,3.01,0,0,0,12.727,3.4a5.676,5.676,0,0,0-2.241-1.494,8.249,8.249,0,0,0-3.03-.551ZM7.457,0A9.543,9.543,0,0,1,11.2.725,6.719,6.719,0,0,1,13.918,2.7a4.22,4.22,0,0,1,0,5.444A6.728,6.728,0,0,1,11.2,10.121a9.533,9.533,0,0,1-3.744.725,10.654,10.654,0,0,1-1.864-.169,8.9,8.9,0,0,1-2.945,1.356,8.823,8.823,0,0,1-.911.169H1.705a.331.331,0,0,1-.217-.085.35.35,0,0,1-.122-.222.217.217,0,0,1-.011-.069.421.421,0,0,1,.005-.069.23.23,0,0,1,.021-.064l.026-.053.037-.058.042-.053.047-.053.042-.047q.053-.064.244-.265a4.008,4.008,0,0,0,.275-.313q.084-.112.238-.307a2.62,2.62,0,0,0,.265-.408q.111-.212.217-.466A6.2,6.2,0,0,1,.752,7.8,4.155,4.155,0,0,1,0,5.423,4.279,4.279,0,0,1,1,2.7,6.723,6.723,0,0,1,3.712.725,9.543,9.543,0,0,1,7.457,0Zm8.706,12.382q.106.254.217.466a2.672,2.672,0,0,0,.265.408q.153.2.238.307t.275.313q.19.2.244.265l.042.047q.032.037.047.053a.449.449,0,0,1,.042.053.475.475,0,0,1,.037.058l.026.053.021.064.005.069-.011.069a.392.392,0,0,1-.138.233.319.319,0,0,1-.233.074,9.054,9.054,0,0,1-.911-.169,8.916,8.916,0,0,1-2.945-1.356,10.654,10.654,0,0,1-1.864.169,8.9,8.9,0,0,1-5-1.4q.614.042.932.042a11.192,11.192,0,0,0,3.273-.477,9.368,9.368,0,0,0,2.8-1.367,7.086,7.086,0,0,0,2.034-2.246,5.451,5.451,0,0,0,.466-4.3A6.268,6.268,0,0,1,18.185,5.7a4.134,4.134,0,0,1,.043,4.814A6.221,6.221,0,0,1,16.163,12.382Z"/>
|
||||
</svg>
|
||||
|
||||
|
||||
'
|
||||
</svg>'
|
||||
),
|
||||
|
||||
|
||||
"glocke" => array(
|
||||
"name" => "Glocke",
|
||||
"image" => '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bell" viewBox="0 0 16 16">
|
||||
<path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/>
|
||||
</svg>'
|
||||
),
|
||||
"einstellungen" => array(
|
||||
"name" => "Einstellungen",
|
||||
"image" => '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sliders" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M11.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M9.05 3a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0V3zM4.5 7a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M2.05 8a2.5 2.5 0 0 1 4.9 0H16v1H6.95a2.5 2.5 0 0 1-4.9 0H0V8zm9.45 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3m-2.45 1a2.5 2.5 0 0 1 4.9 0H16v1h-2.05a2.5 2.5 0 0 1-4.9 0H0v-1z"/>
|
||||
</svg>'
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<?
|
||||
function track_anonymous_login($contact_id) {
|
||||
$id = (int)$contact_id;
|
||||
if ($id <= 0) return;
|
||||
@mysqli_query($GLOBALS['mysql_con'],
|
||||
"INSERT IGNORE INTO stat_login_daily (login_date, main_contact_id) VALUES (CURDATE(), $id)"
|
||||
);
|
||||
}
|
||||
|
||||
support_login_listener();
|
||||
|
||||
|
||||
@@ -41,6 +49,7 @@ function support_login_listener() {
|
||||
$_SESSION["login_error"] = "Dieser Account wurde noch nicht freigeschalten.";
|
||||
}else{
|
||||
$_SESSION['login_id'] = $main_contact['id'];
|
||||
track_anonymous_login($main_contact['id']);
|
||||
header('location:/');
|
||||
}
|
||||
}else{
|
||||
|
||||
@@ -10,6 +10,7 @@ require_once("intranet_functions.inc.php");
|
||||
|
||||
if (isset($_POST["fgztiknxbhhk"])) {
|
||||
$_SESSION['login_id'] = $_POST["fgztiknxbhhk"];
|
||||
track_anonymous_login($_POST["fgztiknxbhhk"]);
|
||||
header('location:/');
|
||||
exit();
|
||||
}
|
||||
@@ -20,7 +21,7 @@ $translation = \DynCom\mysyde\common\classes\Registry::get('translation');
|
||||
|
||||
|
||||
|
||||
$isAzure = true;
|
||||
$isAzure = getenv('DISABLE_MS_AUTH') !== '1';
|
||||
|
||||
if ($isAzure) {
|
||||
|
||||
@@ -73,6 +74,7 @@ if ($isAzure) {
|
||||
// $sql_opt = "SELECT id FROM main_contact WHERE email='".$email_l."'";
|
||||
|
||||
$_SESSION['login_id'] = @mysqli_fetch_assoc($sql_opt)['id'];
|
||||
track_anonymous_login($_SESSION['login_id']);
|
||||
header('location:/');
|
||||
}else {
|
||||
$_SESSION['login_id'] = 0;
|
||||
@@ -260,11 +262,33 @@ getQuote();
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
|
||||
?>
|
||||
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dev Login</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f0f0f0; }
|
||||
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.15); min-width: 280px; }
|
||||
.box h2 { margin: 0 0 1rem; font-size: 1.1rem; color: #c00; }
|
||||
.box label { display: block; margin-bottom: .25rem; font-size: .85rem; color: #555; }
|
||||
.box input[type=number] { width: 100%; box-sizing: border-box; padding: .5rem; border: 1px solid #ccc; border-radius: 4px; margin-bottom: 1rem; font-size: 1rem; }
|
||||
.box button { width: 100%; padding: .6rem; background: #0078d4; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
|
||||
.box button:hover { background: #005fa3; }
|
||||
.note { margin-top: .75rem; font-size: .75rem; color: #999; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>⚠️ Dev-Login (kein Microsoft)</h2>
|
||||
<form method="post">
|
||||
<label>User-ID (aus main_contact)</label>
|
||||
<input type="number" name="fgztiknxbhhk" min="1" required autofocus />
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
<p class="note">Nur lokal verfügbar — DISABLE_MS_AUTH=1 in docker-compose.yml</p>
|
||||
</div>
|
||||
</body>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -41,41 +41,50 @@ if ($messages !== null) {
|
||||
}
|
||||
}
|
||||
|
||||
//Arrays für Select füllen
|
||||
$type_array = array(
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
|
||||
);
|
||||
$type_name_array = array(
|
||||
0 => "Kontaktdaten",
|
||||
1 => "Schwarzes Brett",
|
||||
2 => "Kontakt bearbeiten",
|
||||
3 => "Essenslieferung",
|
||||
4 => "Profil",
|
||||
5 => "Infoboard",
|
||||
6 => "Knowledgecenter",
|
||||
7 => "Firmeninformationen",
|
||||
8 => "Meine Kontakte",
|
||||
9 => "Knowledgecenter - Weitere Beiträge",
|
||||
10 => "E-Learning",
|
||||
11 => "Zertifikate - Kontaktkarte",
|
||||
12 => "Essenslieferung Übersicht",
|
||||
13 => "Zukünftige Termine",
|
||||
14 => "E-Learning - Kontaktkarte",
|
||||
15 => "Playground",
|
||||
16 => "Tasks",
|
||||
17 => "Tasks Vorschau",
|
||||
18 => "Knowledgecenter - Custom Feed",
|
||||
19 => "Arbeitsplatz Übersicht",
|
||||
20 => "Personenkarten",
|
||||
21 => "Organigramm",
|
||||
22 => "PDF Suchen",
|
||||
23 => "PDF Vorschau",
|
||||
24 => "QM Updates",
|
||||
25 => "QR-Code",
|
||||
26 => "Ticketcenter"
|
||||
);
|
||||
//Arrays für Select füllen — Typ-Nummer direkt als Key
|
||||
$types = [
|
||||
1 => "Kontaktdaten",
|
||||
2 => "Schwarzes Brett",
|
||||
3 => "Kontakt bearbeiten",
|
||||
4 => "Essenslieferung",
|
||||
5 => "Profil",
|
||||
6 => "Infoboard",
|
||||
7 => "Knowledgecenter",
|
||||
8 => "Firmeninformationen",
|
||||
9 => "Meine Kontakte",
|
||||
10 => "Knowledgecenter - Weitere Beiträge",
|
||||
11 => "E-Learning",
|
||||
12 => "Zertifikate - Kontaktkarte",
|
||||
13 => "Essenslieferung Übersicht",
|
||||
14 => "Zukünftige Termine",
|
||||
15 => "E-Learning - Kontaktkarte",
|
||||
16 => "Playground",
|
||||
17 => "Tasks",
|
||||
18 => "Tasks Vorschau",
|
||||
19 => "Knowledgecenter - Custom Feed",
|
||||
20 => "Arbeitsplatz Übersicht",
|
||||
21 => "Personenkarten",
|
||||
22 => "Organigramm",
|
||||
23 => "PDF Suchen",
|
||||
24 => "PDF Vorschau",
|
||||
25 => "QM Updates",
|
||||
26 => "QR-Code",
|
||||
27 => "Ticketcenter",
|
||||
28 => "Knowledgecenter Update",
|
||||
29 => "Globale Suche",
|
||||
30 => "Aufgaben Admin Aufgabenliste",
|
||||
31 => "Aufgaben Admin Vorschau",
|
||||
32 => "Aufgaben Admin Eskalations Dashboard",
|
||||
33 => "Aufgaben Admin Freigabe Dashboard",
|
||||
34 => "Aufgaben Admin Kategorien",
|
||||
35 => "Aufgaben User Aufgaben",
|
||||
36 => "Aufgaben User Aufgabe Bearbeitung",
|
||||
37 => "Aufgaben User Überfällig",
|
||||
38 => "Aufgaben User Hinweisleiste",
|
||||
39 => "Aufgaben User Eskalations Overlay",
|
||||
];
|
||||
|
||||
$input_description = (empty($input_intranet['description']) ? $type_name_array[$input_intranet['type']] : $input_intranet['description']);
|
||||
$input_description = (empty($input_intranet['description']) ? ($types[$input_intranet['type']] ?? '') : $input_intranet['description']);
|
||||
|
||||
?>
|
||||
|
||||
@@ -91,7 +100,7 @@ $input_description = (empty($input_intranet['description']) ? $type_name_array[$
|
||||
<td>
|
||||
<div class='label'><label for="input_description"><?= $translation->get('description') ?></label></div>
|
||||
<div class='input'><input type="text" name="input_description" id="input_description" value="<?= $input_description ?>" class="text"></div>
|
||||
<? input_select($translation->get('content_type'),'input_type',$type_array, $type_name_array, $input_intranet['type']); ?>
|
||||
<? input_select($translation->get('content_type'), 'input_type', array_keys($types), array_values($types), $input_intranet['type']); ?>
|
||||
<?php
|
||||
if ($input_intranet['id'] != "" && $input_intranet['type'] == 12 || $input_intranet['type'] == 15) { // E-Learning - Kontaktkarte
|
||||
echo input('Mit Personenkarte anzeigen', "input_learning_certificate_type", "checkbox", $input_intranet["learning_certificate_type"], 255);
|
||||
|
||||
@@ -88,6 +88,45 @@ function intranet_show($sitepart_id) {
|
||||
case 27:
|
||||
$include = MODULE_PATH.'/ticketcenter/views/ticketcenter/show_ticketcenter.inc.php';
|
||||
break;
|
||||
case 28:
|
||||
$include = MODULE_PATH.'/knowledgecenter_update/Controllers/knowledgecenter.inc.php';
|
||||
break;
|
||||
case 29:
|
||||
$include = MODULE_PATH.'/search/views/search_results.php';
|
||||
break;
|
||||
case 30:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/task_list.php';
|
||||
break;
|
||||
case 31:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/task_detail.php';
|
||||
break;
|
||||
case 32:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/escalation_dashboard.php';
|
||||
break;
|
||||
case 33:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/approval_queue.php';
|
||||
break;
|
||||
case 34:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/task_categories.php';
|
||||
break;
|
||||
case 35:
|
||||
$include = MODULE_PATH.'/tasks/pages/user/my_tasks.php';
|
||||
break;
|
||||
case 36:
|
||||
$include = MODULE_PATH.'/tasks/pages/user/task_detail.php';
|
||||
break;
|
||||
case 37:
|
||||
$include = MODULE_PATH.'/tasks/pages/user/overdue_widget.php';
|
||||
break;
|
||||
case 38:
|
||||
$include = MODULE_PATH.'/tasks/pages/user/summary_widget.php';
|
||||
break;
|
||||
case 39:
|
||||
$include = MODULE_PATH.'/tasks/pages/user/escalation_overlay_widget.php';
|
||||
break;
|
||||
case 40:
|
||||
$include = MODULE_PATH.'/tasks/pages/admin/task_form.php';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -572,18 +572,50 @@ function multi_select_permission($collection_id, $type){
|
||||
<script type="text/javascript" src="/plugins/jquery/jquery-3.5.1.min.js"></script>
|
||||
<script type="text/javascript" src="/plugins/Semantic-UI-master/semantic.min.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/Semantic-UI-master/bc-semantic.css?time=<?= filemtime($_SERVER['DOCUMENT_ROOT'] . '/plugins/Semantic-UI-master/bc-semantic.css')?>">
|
||||
<?php
|
||||
$einrichtLinks = [];
|
||||
if ($type == "einricht") {
|
||||
$linkRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
|
||||
while ($lr = mysqli_fetch_assoc($linkRes)) {
|
||||
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<script>
|
||||
|
||||
var $y = jQuery.noConflict();
|
||||
$y( document ).ready(function() {
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($preselect) ?>);
|
||||
var $drop = $y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>');
|
||||
<?php if ($type == "einricht"): ?>
|
||||
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
|
||||
var busy = false;
|
||||
$drop.dropdown({
|
||||
onChange: function(value) {
|
||||
if (busy) return;
|
||||
var current = value ? value.split(',').filter(Boolean) : [];
|
||||
var toAdd = [];
|
||||
current.forEach(function(id) {
|
||||
(einrichtLinks[id] || []).forEach(function(lid) {
|
||||
if (current.indexOf(lid) < 0 && toAdd.indexOf(lid) < 0) toAdd.push(lid);
|
||||
});
|
||||
});
|
||||
if (toAdd.length) {
|
||||
busy = true;
|
||||
$drop.dropdown('set selected', current.concat(toAdd));
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
$drop.dropdown('set selected', <?= json_encode($preselect) ?>);
|
||||
});
|
||||
$("<?php echo "#all_get".$type;?>").click(function() {
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('set selected', <?= json_encode($allselect) ?>);
|
||||
});
|
||||
$("<?php echo "#all_del".$type;?>").click(function() {
|
||||
console.log("delete");
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
|
||||
$y('.websites.ui.fluid.multiple.dropdown.type_<?= $type ?>').dropdown('clear');
|
||||
});
|
||||
</script>
|
||||
<?
|
||||
|
||||
2308
module/knowledgecenter_update/Ajax/Ajax.php
Normal file
2308
module/knowledgecenter_update/Ajax/Ajax.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
/**
|
||||
* Inline-Image-Upload für Quill im Wiki-Editor.
|
||||
* Wird vom imageHandler in post_cardform.php aufgerufen.
|
||||
*
|
||||
* Erwartet (multipart/form-data POST):
|
||||
* - file : Hochgeladenes Bild (jpg/png/gif/webp, <= 5 MB)
|
||||
* - post_id : int > 0 (knowledgecenter_posts.id)
|
||||
*
|
||||
* Antwort (JSON):
|
||||
* { "success": true, "url": "/userdata/knowledgecenter_update/posts/<hash>/inline/<sha>.<ext>" }
|
||||
* { "success": false, "error": "..." } (HTTP 4xx/5xx)
|
||||
*/
|
||||
|
||||
session_start();
|
||||
|
||||
$currDir = __DIR__;
|
||||
$rootDir = dirname(dirname(dirname(dirname($currDir))));
|
||||
$mysydeDir = $rootDir . '/mysyde';
|
||||
|
||||
require_once($rootDir . '/vendor/autoload.php');
|
||||
require_once($mysydeDir . '/frontend/frontend_functions.inc.php');
|
||||
|
||||
$envDir = $rootDir . '/config';
|
||||
$envFilePath = $envDir . '/.env';
|
||||
if (is_dir($envDir) && file_exists($envFilePath) && is_file($envFilePath) && is_readable($envFilePath)) {
|
||||
$dotenv = new \Dotenv\Dotenv($envDir);
|
||||
$dotenv->load();
|
||||
}
|
||||
|
||||
require_once($mysydeDir . '/common/common_functions.inc.php');
|
||||
|
||||
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
|
||||
require_once($connEnv);
|
||||
|
||||
db_connect();
|
||||
|
||||
require_once($rootDir . '/module/intranet/intranet_functions.inc.php');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
function respond_error($msg, $code = 400)
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode(['success' => false, 'error' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_SESSION['login_id'])) {
|
||||
respond_error('Nicht angemeldet.', 401);
|
||||
}
|
||||
$loginId = (int) $_SESSION['login_id'];
|
||||
|
||||
$q = mysqli_query($GLOBALS['mysql_con'], "SELECT id, master_mandant_id FROM main_contact WHERE id = $loginId LIMIT 1");
|
||||
if (!$q || !($contactRow = mysqli_fetch_assoc($q))) {
|
||||
respond_error('Benutzer nicht gefunden.', 401);
|
||||
}
|
||||
$contactId = (int) $contactRow['id'];
|
||||
$masterMandantId = (int) $contactRow['master_mandant_id'];
|
||||
|
||||
$hasRwiki = get_permission_state('r-wiki', $masterMandantId, $contactId) == 1;
|
||||
$hasRwikiBr = get_permission_state('r-wiki-br', $masterMandantId, $contactId) == 1;
|
||||
if (!$hasRwiki && !$hasRwikiBr) {
|
||||
respond_error('Keine Berechtigung.', 403);
|
||||
}
|
||||
|
||||
$postId = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
|
||||
if ($postId <= 0) {
|
||||
respond_error('Ungültige post_id.');
|
||||
}
|
||||
|
||||
$verifyQ = mysqli_query($GLOBALS['mysql_con'], "SELECT id FROM knowledgecenter_posts WHERE id = $postId LIMIT 1");
|
||||
if (!$verifyQ || mysqli_num_rows($verifyQ) === 0) {
|
||||
respond_error('Beitrag existiert nicht.', 404);
|
||||
}
|
||||
|
||||
// Betriebsrat darf Bilder nur für Posts in freigegebenen Kategorien hochladen (inkl. Vererbung)
|
||||
if (!$hasRwiki && $hasRwikiBr) {
|
||||
$brAllowed = false;
|
||||
$brRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT category_id FROM knowledgecenter_category_post WHERE post_id = $postId");
|
||||
while ($brRow = mysqli_fetch_assoc($brRes)) {
|
||||
$catId = (int)$brRow['category_id'];
|
||||
// Baum nach oben absuchen
|
||||
$visited = [];
|
||||
$queue = [$catId];
|
||||
while (!empty($queue)) {
|
||||
$current = array_shift($queue);
|
||||
if (isset($visited[$current])) continue;
|
||||
$visited[$current] = true;
|
||||
$chkRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT betriebsrat_editable FROM knowledgecenter_categories_update WHERE id = $current LIMIT 1");
|
||||
$chkRow = mysqli_fetch_assoc($chkRes);
|
||||
if ($chkRow && (int)$chkRow['betriebsrat_editable'] === 1) {
|
||||
$brAllowed = true;
|
||||
break 2;
|
||||
}
|
||||
$pRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT parent_category_id FROM knowledgecenter_category_links WHERE child_category_id = $current");
|
||||
while ($pRow = mysqli_fetch_assoc($pRes)) {
|
||||
$pid = (int)$pRow['parent_category_id'];
|
||||
if (!isset($visited[$pid])) $queue[] = $pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$brAllowed) {
|
||||
respond_error('Keine Berechtigung für diesen Beitrag.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($_FILES['file']) || !is_array($_FILES['file'])) {
|
||||
respond_error('Keine Datei empfangen.');
|
||||
}
|
||||
$file = $_FILES['file'];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
respond_error('Upload-Fehler (Code ' . (int) $file['error'] . ').');
|
||||
}
|
||||
|
||||
$maxSize = 5 * 1024 * 1024;
|
||||
if (!is_uploaded_file($file['tmp_name'])) {
|
||||
respond_error('Ungültiger Upload.');
|
||||
}
|
||||
if ($file['size'] <= 0 || $file['size'] > $maxSize) {
|
||||
respond_error('Datei zu groß oder leer (max 5 MB).');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
|
||||
if ($finfo) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
$mimeToExt = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
if (!$mime || !isset($mimeToExt[$mime])) {
|
||||
respond_error('Ungültiger Dateityp. Erlaubt: JPG, PNG, GIF, WEBP.');
|
||||
}
|
||||
$ext = $mimeToExt[$mime];
|
||||
|
||||
$hash = substr(sha1('kc_post_' . $postId), 0, 24);
|
||||
$relBase = 'knowledgecenter_update/posts/' . $hash;
|
||||
$absBase = $rootDir . '/userdata/' . $relBase;
|
||||
$absInline = $absBase . '/inline';
|
||||
|
||||
if (!is_dir($absBase) && !@mkdir($absBase, 0775, true)) {
|
||||
respond_error('Post-Ordner konnte nicht erstellt werden.', 500);
|
||||
}
|
||||
$sharedLink = $absBase . '/shared';
|
||||
if (!is_link($sharedLink) && !file_exists($sharedLink)) {
|
||||
@symlink('../../shared', $sharedLink);
|
||||
}
|
||||
if (!is_dir($absInline) && !@mkdir($absInline, 0775, true)) {
|
||||
respond_error('Inline-Ordner konnte nicht erstellt werden.', 500);
|
||||
}
|
||||
|
||||
$sha = hash_file('sha256', $file['tmp_name']);
|
||||
if (!$sha) {
|
||||
respond_error('Hashing fehlgeschlagen.', 500);
|
||||
}
|
||||
$finalName = $sha . '.' . $ext;
|
||||
$finalPath = $absInline . '/' . $finalName;
|
||||
$publicUrl = '/userdata/' . $relBase . '/inline/' . $finalName;
|
||||
|
||||
if (file_exists($finalPath)) {
|
||||
if (filesize($finalPath) === (int) $file['size']) {
|
||||
echo json_encode(['success' => true, 'url' => $publicUrl, 'dedup' => true]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$tmpDest = $absInline . '/.tmp_' . bin2hex(random_bytes(6));
|
||||
if (!move_uploaded_file($file['tmp_name'], $tmpDest)) {
|
||||
respond_error('Datei konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
@chmod($tmpDest, 0664);
|
||||
if (!rename($tmpDest, $finalPath)) {
|
||||
@unlink($tmpDest);
|
||||
respond_error('Datei konnte nicht verschoben werden.', 500);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'url' => $publicUrl]);
|
||||
exit;
|
||||
1447
module/knowledgecenter_update/CSS/CSS.php
Normal file
1447
module/knowledgecenter_update/CSS/CSS.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,976 @@
|
||||
<?php
|
||||
// ini_set('display_errors', 1);
|
||||
// ini_set('display_startup_errors', 1);
|
||||
// error_reporting(E_ALL);
|
||||
|
||||
$translation = \DynCom\mysyde\common\classes\Registry::get("translation");
|
||||
$languageCode = isset($_GET['language']) ? $_GET['language'] : 'de';
|
||||
$languageCode = mysqli_real_escape_string($GLOBALS['mysql_con'], $languageCode);
|
||||
$languageId = $GLOBALS['language']['id'] ?? 1;
|
||||
$isEmbedded = (isset($_GET['embedded']) && $_GET['embedded'] == 1) ? 1 : 0;
|
||||
|
||||
|
||||
// Dependencies einbinden
|
||||
require_once(__DIR__ . '/../Dependencies/Dependencies.php');
|
||||
require_once(__DIR__ . '/../Middleware/Middleware.php');
|
||||
|
||||
if ($_COOKIE['recaptcha'] == 1) { ?>
|
||||
<script src='https://www.google.com/recaptcha/api.js'></script>
|
||||
<?
|
||||
}
|
||||
$c_form = "";
|
||||
|
||||
foreach ($_GET as $key => $value) {
|
||||
if (preg_match('/^c_form\d+$/', $key)) {
|
||||
// Do something with $key and $value
|
||||
$c_form = $key;
|
||||
}
|
||||
}
|
||||
|
||||
$translation = \DynCom\mysyde\common\classes\Registry::get("translation");
|
||||
$spid = 0;
|
||||
$userId = 0;
|
||||
$addUser = FALSE;
|
||||
if (isset($_GET[$c_form]) && !empty($_POST)) {
|
||||
|
||||
$sitepart_id = $_GET[$c_form];
|
||||
|
||||
|
||||
$queryFormData = "SELECT * FROM contactform_header WHERE id = '" . $sitepart_id . "'";
|
||||
|
||||
$resultFormData = @mysqli_query($GLOBALS['mysql_con'], $queryFormData);
|
||||
$contactFormData = @mysqli_fetch_array($resultFormData);
|
||||
$header_id = $contactFormData['id'];
|
||||
// var_dump($header_id);
|
||||
// echo "test";
|
||||
|
||||
$requestmassage = "";
|
||||
$success = false;
|
||||
$_REQUEST['input_contactform'] = $header_id;
|
||||
// daten sammeln
|
||||
$query = "SELECT * FROM contactform_header WHERE id = '" . $sitepart_id . "'";
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$contactform_header = @mysqli_fetch_array($result);
|
||||
$filetypeError = FALSE;
|
||||
$error = FALSE;
|
||||
$captchError = FALSE;
|
||||
$message = "";
|
||||
/** Neu Abfrage aus welcher Seite Anfrage geschickt wurde */
|
||||
$navigation_pfad = $_SERVER['HTTP_HOST'];
|
||||
$navigation_pfad .= navigation_path($GLOBALS["site"], $GLOBALS["language"], $GLOBALS["navigation"]);
|
||||
// $navigation_pfad .= $_SERVER['REQUEST_URI'];
|
||||
|
||||
$ajaxHelper = "";
|
||||
if (isset($_GET['ajax'])) {
|
||||
$ajaxHelper = "~|~";
|
||||
}
|
||||
//recaptcha
|
||||
|
||||
|
||||
|
||||
|
||||
if ($contactform_header['recaptcha_active'] == 1) {
|
||||
$secretKey = $GLOBALS['site']['secretkey'];
|
||||
$responseKey = $_POST['g-recaptcha-response'];
|
||||
$userIP = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
$url = "https://www.google.com/recaptcha/api/siteverify?secret=$secretKey&response=$responseKey&remoteip=$userIP";
|
||||
$response = file_get_contents($url);
|
||||
|
||||
$data = json_decode($response);
|
||||
if ($data->success == FALSE) {
|
||||
|
||||
$error = TRUE;
|
||||
$captchError = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$query = "SELECT * FROM contactform_line WHERE header_id = '" . $sitepart_id . "' ORDER BY sorting ASC";
|
||||
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
if (@mysqli_num_rows($result) > 0) {
|
||||
|
||||
// Initialize helpers
|
||||
unset($field_errors);
|
||||
$field_errors = [];
|
||||
$attachements = []; // raw filenames in /userdata/mail_attachments/
|
||||
$file = []; // final paths for mail_create
|
||||
$submission_data = [];
|
||||
$filetypeError = FALSE;
|
||||
$error = FALSE;
|
||||
|
||||
// contact form linked in collection (keep your existing logic)
|
||||
if (
|
||||
isset($_POST['contact_field_id'], $_POST['main_collection_id']) &&
|
||||
$_POST['contact_field_id'] != 0 && $_POST['main_collection_id'] != 0
|
||||
) {
|
||||
$queryLinked = "SELECT * FROM main_collection_contact_linked WHERE main_collection_id = " . (int) $_POST['main_collection_id'] . " AND field_id = " . (int) $_POST['contact_field_id'];
|
||||
$resultLinked = @mysqli_query($GLOBALS['mysql_con'], $queryLinked);
|
||||
if (@mysqli_num_rows($resultLinked) > 0) {
|
||||
$row = @mysqli_fetch_array($resultLinked);
|
||||
$contactform_header["recipient_email"] = $row['receiver_email'];
|
||||
$contactform_header["recipient_name"] = $row['receiver_name'];
|
||||
}
|
||||
|
||||
$queryCollection = "SELECT mc.*, mcs.collection_type FROM main_collection as mc INNER JOIN main_collection_setup as mcs on mc.main_collection_setup_id = mcs.id WHERE mc.id = '" . (int) $_POST['main_collection_id'] . "' ";
|
||||
$resultCollection = @mysqli_query($GLOBALS['mysql_con'], $queryCollection);
|
||||
|
||||
if (@mysqli_num_rows($resultCollection) > 0) {
|
||||
$collection = @mysqli_fetch_array($resultCollection);
|
||||
if ((int) $collection['collection_type'] === 2) {
|
||||
$queryLinked = "SELECT * FROM bewerbung WHERE main_collection_id = " . (int) $_POST['main_collection_id'] . " AND contact_id = " . (int) $header_id . " AND user_id = " . (int) $userId;
|
||||
$resultLinked = @mysqli_query($GLOBALS['mysql_con'], $queryLinked);
|
||||
if (@mysqli_num_rows($resultLinked) > 0) {
|
||||
$addUser = FALSE;
|
||||
$error = TRUE;
|
||||
$requestmassage .= "<div class=\"danger infobox\">" . $translation->get('user_exist') . "<br></div>";
|
||||
} else {
|
||||
$queryInsert = "INSERT INTO bewerbung (contact_id,user_id,main_collection_id, modified_date)
|
||||
VALUES ('" . (int) $header_id . "', '" . (int) $userId . "', '" . (int) $_POST['main_collection_id'] . "', '" . time() . "')";
|
||||
$addUser = TRUE;
|
||||
@mysqli_query($GLOBALS['mysql_con'], $queryInsert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate all fields and build message + attachments + submission_data
|
||||
while ($field = @mysqli_fetch_array($result)) {
|
||||
$field_id = (int) $field['id'];
|
||||
$field_code = $field['code']; // may be null in legacy data
|
||||
$input_key = "input_" . ($field_code ?? $field_id);
|
||||
$input_value = $_POST[$input_key] ?? '';
|
||||
$linked_value = '';
|
||||
|
||||
// Basic mandatory check (skip special cases & file types)
|
||||
if (
|
||||
($field["code"] !== 'registration_name' && $field["code"] !== 'registration_email') &&
|
||||
empty($_POST[$input_key]) &&
|
||||
!empty($field["mandatory"]) &&
|
||||
// skip file(8), radio(10) special handling in their cases
|
||||
($field["typ"] != 8 && $field["typ"] != 10)
|
||||
) {
|
||||
$error = TRUE;
|
||||
$field_errors[] = $field_id;
|
||||
}
|
||||
|
||||
// --- FULL SWITCH-CASE ---
|
||||
switch ($field["typ"]) {
|
||||
case 1: // Single-line text
|
||||
if ($field['code'] === 'subject' && isset($_POST[$input_key])) {
|
||||
$contactform_header["subject"] = trim((string) $_POST[$input_key]);
|
||||
}
|
||||
$val = $_POST[$input_key] ?? '';
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
|
||||
$input_value = $val;
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 2: // Multiline textarea
|
||||
$raw = $_POST[$input_key] ?? '';
|
||||
// Normalize newlines for mail rendering
|
||||
$search = array('\r\n', '\n\r', '\r', '\n');
|
||||
$replace = array(PHP_EOL, PHP_EOL, PHP_EOL, PHP_EOL);
|
||||
$clean = str_replace($search, $replace, $raw);
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $clean . "\n";
|
||||
$input_value = $clean;
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 3: // Boolean (checkbox)
|
||||
$checked = !empty($_POST[$input_key]);
|
||||
$boolText = $checked ? "Ja" : "Nein";
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $boolText . "\n";
|
||||
$input_value = $checked ? '1' : '0';
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 4: // Select / special codes (mandant, user)
|
||||
if ($field['code'] === 'mandant') {
|
||||
// Hidden/system value — not printed, but stored
|
||||
$input_value = $_POST[$input_key] ?? '';
|
||||
$linked_value = '';
|
||||
break;
|
||||
} elseif ($field['code'] === 'user') {
|
||||
$username = $GLOBALS['main_contact']['name'] ?? '';
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $username . "\n";
|
||||
$input_value = $username;
|
||||
$linked_value = '';
|
||||
break;
|
||||
} else {
|
||||
$val = $_POST[$input_key] ?? '';
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
|
||||
$input_value = $val;
|
||||
$linked_value = '';
|
||||
}
|
||||
|
||||
// Linked fields for multistep/survey (optional free-text per option)
|
||||
if (!empty($contactFormData['is_multistep']) || !empty($contactFormData['is_survey'])) {
|
||||
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
|
||||
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
|
||||
if ($resOpt) {
|
||||
while ($opt = @mysqli_fetch_array($resOpt)) {
|
||||
if (!empty($opt['linked_field'])) {
|
||||
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
|
||||
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
|
||||
$message .= $_POST[$lk] . "\n";
|
||||
$linked_value = $_POST[$lk]; // store last linked input
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 5: // Static label/header
|
||||
$message .= "<strong>" . $field["name"] . "</strong>\n";
|
||||
$input_value = ''; // purely presentational
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 6: // Separator / line break
|
||||
$message .= "\n";
|
||||
$input_value = '';
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 7: // Title taken from input
|
||||
$val = $_POST[$input_key] ?? '';
|
||||
$message .= "<strong>" . $val . "</strong>\n";
|
||||
$input_value = $val;
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
case 8: // File upload (single or multiple)
|
||||
// Ensure arrays exist
|
||||
if (!isset($attachements) || !is_array($attachements)) {
|
||||
$attachements = [];
|
||||
}
|
||||
|
||||
// HTML input name expected as "input_<code>" or "input_<code>[]"
|
||||
$uploadField = $input_key;
|
||||
|
||||
// Normalize $_FILES to array of file hashes
|
||||
$files = [];
|
||||
if (isset($_FILES[$uploadField])) {
|
||||
if (is_array($_FILES[$uploadField]['name'])) {
|
||||
$cnt = count($_FILES[$uploadField]['name']);
|
||||
for ($i = 0; $i < $cnt; $i++) {
|
||||
$files[] = [
|
||||
'name' => $_FILES[$uploadField]['name'][$i],
|
||||
'type' => $_FILES[$uploadField]['type'][$i],
|
||||
'tmp_name' => $_FILES[$uploadField]['tmp_name'][$i],
|
||||
'error' => $_FILES[$uploadField]['error'][$i],
|
||||
'size' => $_FILES[$uploadField]['size'][$i],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$files[] = $_FILES[$uploadField];
|
||||
}
|
||||
}
|
||||
|
||||
// Mandatory check for files
|
||||
if ((empty($files) || ($files[0]['name'] ?? '') === '') && !empty($field["mandatory"])) {
|
||||
$error = TRUE;
|
||||
$input_value = '';
|
||||
$linked_value = '';
|
||||
break;
|
||||
}
|
||||
if (empty($files) || ($files[0]['name'] ?? '') === '') {
|
||||
// No files and not mandatory
|
||||
$input_value = '';
|
||||
$linked_value = '';
|
||||
break;
|
||||
}
|
||||
|
||||
// Target directory
|
||||
$uploadDir = rtrim(dirname(dirname(dirname(__DIR__))), "/") . '/userdata/mail_attachments/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
@mkdir($uploadDir, 0775, true);
|
||||
}
|
||||
|
||||
// Extension policies
|
||||
$allowed = ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'zip', 'rar', '7z', 'txt', 'csv', 'mp4', 'mov', 'avi', 'mkv'];
|
||||
$blocked = ['php', 'phtml', 'phar', 'cgi', 'pl', 'exe', 'sh', 'bat', 'cmd', 'com', 'js', 'msi'];
|
||||
|
||||
$uploadedNames = [];
|
||||
foreach ($files as $f) {
|
||||
// Skip unsuccessful items
|
||||
if (!isset($f['error']) || $f['error'] !== UPLOAD_ERR_OK || ($f['name'] ?? '') === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
|
||||
if (in_array($ext, $blocked, true) || ($ext !== '' && !in_array($ext, $allowed, true))) {
|
||||
$filetypeError = TRUE;
|
||||
$error = TRUE;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional: size limit (e.g., 25MB)
|
||||
// if (($f['size'] ?? 0) > 25 * 1024 * 1024) { $filetypeError = TRUE; $error = TRUE; continue; }
|
||||
|
||||
// Sanitize filename & ensure uniqueness
|
||||
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', $f['name']);
|
||||
$dest = $uploadDir . $base;
|
||||
$iUniq = 1;
|
||||
$extSuffix = $ext ? ('.' . $ext) : '';
|
||||
while (file_exists($dest)) {
|
||||
$stem = pathinfo($base, PATHINFO_FILENAME);
|
||||
$dest = $uploadDir . $stem . "_" . $iUniq . $extSuffix;
|
||||
$iUniq++;
|
||||
}
|
||||
|
||||
if (@move_uploaded_file($f['tmp_name'], $dest)) {
|
||||
$attachements[] = basename($dest);
|
||||
$uploadedNames[] = basename($dest);
|
||||
} else {
|
||||
$filetypeError = TRUE;
|
||||
$error = TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark field value & show list in message
|
||||
$input_value = $uploadedNames ? 'FILE_ATTACHMENT' : '';
|
||||
$linked_value = '';
|
||||
if (!empty($uploadedNames)) {
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . implode(', ', $uploadedNames) . "\n";
|
||||
}
|
||||
break;
|
||||
|
||||
case 9: // Checkbox group (multi-select) — uses field id in POST
|
||||
$vals = $_POST["input_" . $field_id] ?? null;
|
||||
if (is_array($vals) && !empty($vals)) {
|
||||
$lines = implode('<br>', array_map('trim', $vals));
|
||||
$message .= "<strong>" . $field["name"] . ":</strong><br>" . $lines . "\n";
|
||||
$input_value = implode(', ', $vals);
|
||||
} else {
|
||||
$input_value = '';
|
||||
}
|
||||
$linked_value = '';
|
||||
|
||||
// Linked inputs per chosen option (multistep)
|
||||
if (!empty($contactFormData['is_multistep'])) {
|
||||
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
|
||||
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
|
||||
if ($resOpt) {
|
||||
while ($opt = @mysqli_fetch_array($resOpt)) {
|
||||
if (!empty($opt['linked_field'])) {
|
||||
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
|
||||
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
|
||||
$message .= $_POST[$lk] . "\n";
|
||||
$linked_value = $_POST[$lk];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 10: // Single choice (radio/select) — uses field id in POST
|
||||
$chosen = $_POST["input_" . $field_id] ?? '';
|
||||
if ($chosen !== '') {
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $chosen . "\n";
|
||||
}
|
||||
$input_value = $chosen;
|
||||
$linked_value = '';
|
||||
|
||||
// Linked input for selected option (multistep/survey)
|
||||
if (!empty($contactFormData['is_multistep']) || !empty($contactFormData['is_survey'])) {
|
||||
$qOpt = 'SELECT * FROM contactform_line_option WHERE line_id = ' . $field_id;
|
||||
$resOpt = @mysqli_query($GLOBALS['mysql_con'], $qOpt);
|
||||
if ($resOpt) {
|
||||
while ($opt = @mysqli_fetch_array($resOpt)) {
|
||||
if ($opt['option_string'] === $chosen && !empty($opt['linked_field'])) {
|
||||
$lk = "inputlinked_" . $field_id . '_' . (int) $opt['id'];
|
||||
if (isset($_POST[$lk]) && $_POST[$lk] !== '') {
|
||||
$message .= $_POST[$lk] . "\n";
|
||||
$linked_value = $_POST[$lk];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 11: // Date
|
||||
$raw = $_POST[$input_key] ?? '';
|
||||
$formatted = '';
|
||||
if ($raw !== '') {
|
||||
try {
|
||||
$dt = new DateTime($raw);
|
||||
$formatted = $dt->format('d.m.Y');
|
||||
} catch (Throwable $e) {
|
||||
$formatted = $raw; // fallback
|
||||
}
|
||||
}
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $formatted . "\n";
|
||||
$input_value = $raw; // store raw in DB
|
||||
$linked_value = '';
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown type — store raw if present
|
||||
$val = $_POST[$input_key] ?? '';
|
||||
if ($val !== '') {
|
||||
$message .= "<strong>" . $field["name"] . ":</strong> " . $val . "\n";
|
||||
}
|
||||
$input_value = $val;
|
||||
$linked_value = '';
|
||||
break;
|
||||
}
|
||||
|
||||
// Persist a normalized snapshot of the user's input for each field
|
||||
$submission_data[] = [
|
||||
'field_id' => $field_id,
|
||||
'value' => is_array($input_value) ? implode(', ', $input_value) : (string) $input_value,
|
||||
'linked_value' => is_array($linked_value) ? implode(', ', $linked_value) : (string) $linked_value,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (($_POST["input_captcha1"] <> "") | ((time() - $_POST["input_captcha2"]) < 5)) {
|
||||
$error = TRUE;
|
||||
$requestmassage .= $GLOBALS['tc']['contactform_spam'];
|
||||
}
|
||||
|
||||
if ($captchError == TRUE) {
|
||||
echo $ajaxHelper . "<div class=\"danger infobox\">Bitte verwenden Sie das Captcha <br></div>" . $ajaxHelper;
|
||||
} else {
|
||||
echo $ajaxHelper . "" . $ajaxHelper;
|
||||
}
|
||||
if ($filetypeError === true) {
|
||||
echo $ajaxHelper . "<div class=\"danger infobox\">Upload von PHP Dateien nicht erlaubt</div>" . $ajaxHelper;
|
||||
}
|
||||
|
||||
|
||||
//DUP breadcrumb upload mehrere Dateien update
|
||||
if (!$error) {
|
||||
//fügt Daten ins Array
|
||||
foreach ($attachements as $attachement_line) {
|
||||
$file[] = "/userdata/mail_attachments/" . $attachement_line;
|
||||
}
|
||||
|
||||
// AUTOMATISCHE ANTWORT
|
||||
if ($contactform_header["auto_answer"] != "" && $GLOBALS['main_contact']['email'] != "") {
|
||||
if (
|
||||
mail_create(
|
||||
$contactform_header["subject"],
|
||||
$contactform_header["auto_answer"],
|
||||
$contactform_header["sender_email"],
|
||||
$GLOBALS['main_contact']['email'],
|
||||
$contactform_header["sender_name"],
|
||||
$GLOBALS['main_contact']['email'],
|
||||
FALSE,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
$contact_form_id
|
||||
)
|
||||
) {
|
||||
|
||||
mail_send();
|
||||
clearstatcache();
|
||||
$success = true;
|
||||
$requestmassage .= $GLOBALS['tc']['contactform_success'];
|
||||
foreach ($_POST as $key => $value) {
|
||||
unset($_POST[$key]);
|
||||
}
|
||||
} else {
|
||||
$error = TRUE;
|
||||
$requestmassage .= $translation->get('not_send1');
|
||||
}
|
||||
}
|
||||
if ($sitepart_id == 1) {
|
||||
$mandant_id = $GLOBALS["main_contact"]['master_mandant_id'];
|
||||
$contact_id = $GLOBALS["main_contact"]['id'];
|
||||
// $query = "SELECT * FROM main_contact_link LEFT JOIN main_collection as mc on mc.id = plg.portal_group_id WHERE plg.portal_login_id ='".$login_id."' AND plg.portal_group_id = '".$group_id."' AND plg.email != ''";
|
||||
// $result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
// $res = mysqli_fetch_array($result);
|
||||
|
||||
$contactform_header2["sender_email"] = $contactform_header["sender_email"];
|
||||
$contactform_header2["sender_name"] = $contactform_header["sender_name"];
|
||||
$contactform_header["sender_email"] = $GLOBALS["main_contact"]['email'];
|
||||
$contactform_header["sender_name"] = $GLOBALS["main_contact"]['name'];
|
||||
$contactform_header["subject"] = "Abwesenheit | {$GLOBALS["main_contact"]['name']}";
|
||||
}
|
||||
if ($sitepart_id == 2) {
|
||||
$contactform_header["sender_email"] = $GLOBALS["main_contact"]['email'];
|
||||
$contactform_header["sender_name"] = $GLOBALS["main_contact"]['name'];
|
||||
|
||||
$query = 'SELECT * from main_contact WHERE id = ' . $_REQUEST['input_user'] . ' LIMIT 1';
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
if (@mysqli_num_rows($result) == 1) {
|
||||
$user = @mysqli_fetch_array($result);
|
||||
if ($user['email'] != NULL && $user['email'] != '') {
|
||||
$contactform_header["recipient_name"] = $user['name'];
|
||||
$contactform_header["recipient_email"] = $user['email'];
|
||||
}
|
||||
}
|
||||
create_notification($_REQUEST['input_user'], $message);
|
||||
}
|
||||
|
||||
// Sende E-Mail an Firmen-Email
|
||||
if ($sitepart_id == 1) {
|
||||
$query = "SELECT * FROM main_contact_department WHERE main_contact_id = '" . $GLOBALS["main_contact"]['id'] . "' AND sorting = 1";
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
|
||||
$input_arbeitsunfaehigkeitsbescheinigung = $_POST['input_arbeitsunfaehigkeitsbescheinigung'];
|
||||
$input_date2 = new DateTime($_POST['input_date2']);
|
||||
$input_date = new DateTime($_POST['input_date']);
|
||||
$input_besprechung = $_POST['input_besprechung'];
|
||||
$input_ansprechpartner = $_POST['input_ansprechpartner'];
|
||||
|
||||
while ($res = mysqli_fetch_array($result)) {
|
||||
$query = "SELECT * FROM main_mandant WHERE id = " . $res['main_mandant_id'];
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$res_mandant = @mysqli_fetch_array($result);
|
||||
|
||||
$contactform_header2['recipient_email'] = $res_mandant['mail_team'];
|
||||
$contactform_header2['recipient_name'] = $res_mandant['description'];
|
||||
|
||||
$message2 = "Liebes Team, \n\n";
|
||||
|
||||
if (isset($_POST['serial_code']) && $_POST['serial_code'] != "") {
|
||||
$randomString = $_POST['serial_code'];
|
||||
} else {
|
||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$charactersLength = strlen($characters);
|
||||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$randomString .= $characters[rand(0, $charactersLength - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($input_arbeitsunfaehigkeitsbescheinigung) && $input_date2 != "") {
|
||||
$message2 .= $GLOBALS["main_contact"]['name'] . " ist bis einschließlich den " . $input_date2->format('d.m.Y') . " abwesend.\n";
|
||||
$check_query = "SELECT events_type.id, events_type.sick_note FROM events_type WHERE events_type.sick_note = 1";
|
||||
$check_result = mysqli_query($GLOBALS['mysql_con'], $check_query);
|
||||
$check_row = mysqli_fetch_assoc($check_result);
|
||||
$event_type_id = $check_row["id"];
|
||||
$created_date = date(format: "Y-m-d H:i:s");
|
||||
$modifiedDate = date(format: "Y-m-d H:i:s");
|
||||
$date = clone $input_date;
|
||||
while ($date <= $input_date2) {
|
||||
$delete_query = "DELETE FROM events WHERE date_from = '" . $date->format('Y-m-d') . "' AND main_contact_id = '" . $GLOBALS['main_contact']['id'] . "'";
|
||||
@mysqli_query($GLOBALS['mysql_con'], $delete_query);
|
||||
$insert_query = "INSERT INTO events (
|
||||
date_from,
|
||||
date_to,
|
||||
main_contact_id,
|
||||
event_type_id,
|
||||
serial_code,
|
||||
created_date,
|
||||
modified_date,
|
||||
all_day,
|
||||
active
|
||||
) VALUES (
|
||||
'" . $date->format('Y-m-d') . "',
|
||||
'" . $date->format('Y-m-d') . "',
|
||||
'" . $GLOBALS['main_contact']['id'] . "',
|
||||
'" . $event_type_id . "',
|
||||
'" . $randomString . "',
|
||||
'" . $created_date . "',
|
||||
'" . $modifiedDate . "',
|
||||
1,
|
||||
1
|
||||
)";
|
||||
@mysqli_query($GLOBALS['mysql_con'], $insert_query);
|
||||
$date->modify('+1 day');
|
||||
}
|
||||
} else {
|
||||
$message2 .= $GLOBALS["main_contact"]['name'] . " hat sich für den " . $input_date->format('d.m.Y') . " abgemeldet.\n";
|
||||
$delete_query = "DELETE FROM events WHERE date_from = '" . $input_date->format('Y-m-d') . "' AND main_contact_id = '" . $GLOBALS['main_contact']['id'] . "'";
|
||||
@mysqli_query($GLOBALS['mysql_con'], $delete_query);
|
||||
$check_query = "SELECT events_type.id, events_type.sick_note FROM events_type WHERE events_type.sick_note = 1";
|
||||
$check_result = mysqli_query($GLOBALS['mysql_con'], $check_query);
|
||||
$check_row = mysqli_fetch_assoc($check_result);
|
||||
$event_type_id = $check_row["id"];
|
||||
$created_date = date(format: "Y-m-d H:i:s");
|
||||
$modifiedDate = date(format: "Y-m-d H:i:s");
|
||||
$insert_query = "INSERT INTO events (
|
||||
date_from,
|
||||
date_to,
|
||||
main_contact_id,
|
||||
event_type_id,
|
||||
serial_code,
|
||||
created_date,
|
||||
modified_date,
|
||||
all_day,
|
||||
active
|
||||
) VALUES (
|
||||
'" . $date->format('Y-m-d') . "',
|
||||
'" . $date->format('Y-m-d') . "',
|
||||
'" . $GLOBALS['main_contact']['id'] . "',
|
||||
'" . $event_type_id . "',
|
||||
'" . $randomString . "',
|
||||
'" . $created_date . "',
|
||||
'" . $modifiedDate . "',
|
||||
1,
|
||||
1
|
||||
)";
|
||||
@mysqli_query($GLOBALS['mysql_con'], $insert_query);
|
||||
}
|
||||
|
||||
if (isset($input_besprechung) && $input_ansprechpartner != "") {
|
||||
$message2 .= "Die telefonische Besprechung zur Übergabe von Aufgaben und Terminen erfolgte mit " . $input_ansprechpartner . ".\n";
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
mail_create(
|
||||
$contactform_header["subject"],
|
||||
$message2,
|
||||
$contactform_header2["sender_email"],
|
||||
$contactform_header2["recipient_email"],
|
||||
$contactform_header2["sender_name"],
|
||||
$contactform_header2["recipient_name"],
|
||||
FALSE,
|
||||
$file,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
""
|
||||
)
|
||||
) {
|
||||
mail_send();
|
||||
unset($file);
|
||||
clearstatcache();
|
||||
$success = true;
|
||||
$requestmassage .= $GLOBALS['tc']['contactform_success'];
|
||||
foreach ($_POST as $key => $value) {
|
||||
unset($_POST[$key]);
|
||||
}
|
||||
} else {
|
||||
$error = TRUE;
|
||||
$requestmassage .= $translation->get('not_send2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($GLOBAL["ws_bestell_result"] == true) {
|
||||
$query = "SELECT main_contact.email FROM main_contact JOIN main_contact_department ON main_contact.id = main_contact_department.main_contact_id AND main_role_id = '3' AND main_mandant_id = " . $GLOBALS["main_contact"]["current_mandant_id"];
|
||||
$result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($email = @mysqli_fetch_array($result)) {
|
||||
$contactform_header["recipient_email"] .= "; " . $email["email"];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$bccEmails = [];
|
||||
|
||||
$sender_id = (int) $GLOBALS['main_contact']['id'];
|
||||
$sender_mandant_id = (int) $GLOBALS['main_contact']['current_mandant_id'];
|
||||
|
||||
$responsibleString = $contactform_header['responsible_person'] ?? '';
|
||||
$responsibleIds = array_filter(array_map('intval', explode(',', $responsibleString)));
|
||||
|
||||
if (!empty($responsibleIds) && $sender_mandant_id) {
|
||||
foreach ($responsibleIds as $pid) {
|
||||
$pid = (int) $pid;
|
||||
|
||||
$check = mysqli_query($GLOBALS['mysql_con'], "
|
||||
SELECT 1 FROM main_contact_department
|
||||
WHERE main_contact_id = $pid
|
||||
AND main_mandant_id = $sender_mandant_id
|
||||
AND active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
if (mysqli_num_rows($check)) {
|
||||
$emailRes = mysqli_query($GLOBALS['mysql_con'], "
|
||||
SELECT email FROM main_contact
|
||||
WHERE id = $pid AND email != ''
|
||||
LIMIT 1
|
||||
");
|
||||
if ($emailRow = mysqli_fetch_assoc($emailRes)) {
|
||||
$bccEmails[] = $emailRow['email'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add mandant leaders to BCC if mandant_leader_in_cc is enabled
|
||||
if ((int)($contactform_header["mandant_leader_in_cc"] ?? 0) === 1 && $sender_mandant_id > 0) {
|
||||
$leadersRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT mc.email FROM main_mandant_leader mml
|
||||
INNER JOIN main_contact mc ON mc.id = mml.main_contact_id
|
||||
WHERE mml.main_mandant_id = " . $sender_mandant_id . "
|
||||
AND mc.active = 1 AND mc.email != ''
|
||||
ORDER BY mml.sorting ASC"
|
||||
);
|
||||
while ($leader = mysqli_fetch_assoc($leadersRes)) {
|
||||
$bccEmails[] = $leader['email'];
|
||||
}
|
||||
}
|
||||
|
||||
$bccString = implode(',', array_unique($bccEmails));
|
||||
|
||||
$formName = $contactform_header['description'] ?? '—';
|
||||
|
||||
$applicantName = $GLOBALS['main_contact']['name'] ?? 'Unbekannt';
|
||||
|
||||
$mandantName = '';
|
||||
$mid = (int) $GLOBALS['main_contact']['current_mandant_id'];
|
||||
if ($mid) {
|
||||
$row = mysqli_fetch_assoc(
|
||||
mysqli_query(
|
||||
$GLOBALS['mysql_con'],
|
||||
"SELECT description FROM main_mandant WHERE id = $mid LIMIT 1"
|
||||
)
|
||||
);
|
||||
$mandantName = $row['description'] ?: 'ID ' . $mid;
|
||||
}
|
||||
|
||||
$currentDt = date('d.m.Y H:i');
|
||||
|
||||
$formName = htmlspecialchars($contactform_header['description'] ?? '—', ENT_QUOTES);
|
||||
|
||||
$applicant = htmlspecialchars($GLOBALS['main_contact']['name'] ?? 'Unbekannt', ENT_QUOTES);
|
||||
|
||||
$mandant = '';
|
||||
$mid = (int) $GLOBALS['main_contact']['current_mandant_id'];
|
||||
if ($mid) {
|
||||
$row = mysqli_fetch_assoc(
|
||||
mysqli_query(
|
||||
$GLOBALS['mysql_con'],
|
||||
"SELECT description FROM main_mandant WHERE id = $mid LIMIT 1"
|
||||
)
|
||||
);
|
||||
$mandant = htmlspecialchars($row['description'] ?: 'ID ' . $mid, ENT_QUOTES);
|
||||
}
|
||||
|
||||
$currDt = date('d.m.Y H:i');
|
||||
|
||||
$metaHeader = "
|
||||
<h2 style='margin-top:0;'>Neue Formular-Anfrage</h2>
|
||||
<p style='margin:0 0 0 0;'><strong>Formular:</strong> $formName</p>
|
||||
<p style='margin:0 0 0 0;'><strong>Erstellt von:</strong> $applicant</p>
|
||||
<p style='margin:0 0 0 0;'><strong>Mandant:</strong> $mandant</p>
|
||||
<p style='margin:0 0 0 0;'><strong>Datum:</strong> $currDt</p>
|
||||
";
|
||||
|
||||
$message = $metaHeader . trim($message);
|
||||
// fa wenn betreff ist leer
|
||||
// $contactform_header["subject"] = trim((string)$contactform_header["subject"]);
|
||||
// if ($contactform_header["subject"] === '') {
|
||||
// $contactform_header["subject"] = 'Kontaktformular';
|
||||
// }
|
||||
|
||||
if (
|
||||
mail_create(
|
||||
$contactform_header["subject"],
|
||||
$message,
|
||||
$contactform_header["sender_email"],
|
||||
$contactform_header["recipient_email"],
|
||||
$contactform_header["sender_name"],
|
||||
$contactform_header["recipient_name"],
|
||||
FALSE,
|
||||
$file,
|
||||
$bccString,
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
$spid
|
||||
)
|
||||
) {
|
||||
mail_send();
|
||||
unset($file);
|
||||
clearstatcache();
|
||||
$success = true;
|
||||
$requestmassage .= $GLOBALS['tc']['contactform_success'];
|
||||
foreach ($_POST as $key => $value) {
|
||||
unset($_POST[$key]);
|
||||
}
|
||||
|
||||
} else {
|
||||
$error = TRUE;
|
||||
$requestmassage .= $translation->get('not_send3');
|
||||
}
|
||||
} else {
|
||||
$error = TRUE;
|
||||
$requestmassage .= $GLOBALS['tc']['mandatory_fields_error'];
|
||||
if ($filetypeError === TRUE) {
|
||||
$requestmassage .= $GLOBALS['tc']['contactform_error_php'];
|
||||
}
|
||||
}
|
||||
|
||||
$mail_query = mysqli_query($GLOBALS['mysql_con'], "SELECT id FROM main_mail_log ORDER BY id DESC LIMIT 1");
|
||||
$mail_row = mysqli_fetch_assoc($mail_query);
|
||||
$mail_id = $mail_row['id'] ?? 0;
|
||||
|
||||
|
||||
foreach ($submission_data as $entry) {
|
||||
$query_entry = "INSERT INTO contactform_entries (mail_id, line_id, value)
|
||||
VALUES (
|
||||
$mail_id,
|
||||
" . intval($entry['field_id']) . ",
|
||||
'" . mysqli_real_escape_string($GLOBALS['mysql_con'], $entry['value']) . "'
|
||||
)";
|
||||
mysqli_query($GLOBALS['mysql_con'], $query_entry);
|
||||
}
|
||||
|
||||
|
||||
if ($error || $success) {
|
||||
if ($success) {
|
||||
get_requestbox($requestmassage, "", "success");
|
||||
} else {
|
||||
get_requestbox($requestmassage, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper-Funktion zur Berechtigungsprüfung
|
||||
function require_permission($permissionCode)
|
||||
{
|
||||
if (
|
||||
get_permission_state(
|
||||
$permissionCode,
|
||||
$GLOBALS['main_contact']['master_mandant_id'],
|
||||
$GLOBALS['main_contact']['id']
|
||||
) != 1
|
||||
) {
|
||||
echo "Sie haben nicht die notwendigen Berechtigungen, um diese Seite zu sehen.";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüft, ob der User voller Wiki-Redakteur ist (r-wiki)
|
||||
function is_wiki_redakteur(): bool
|
||||
{
|
||||
return get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1;
|
||||
}
|
||||
|
||||
// Prüft, ob der User Betriebsrat-Wiki-Recht hat (r-wiki-br)
|
||||
function is_wiki_betriebsrat(): bool
|
||||
{
|
||||
return get_permission_state('r-wiki-br', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1;
|
||||
}
|
||||
|
||||
// Prüft, ob der User irgendeins der Wiki-Bearbeitungsrechte hat
|
||||
function has_wiki_edit_permission(): bool
|
||||
{
|
||||
return is_wiki_redakteur() || is_wiki_betriebsrat();
|
||||
}
|
||||
|
||||
function require_wiki_edit_permission(): void
|
||||
{
|
||||
if (!has_wiki_edit_permission()) {
|
||||
echo "Sie haben nicht die notwendigen Berechtigungen, um diese Seite zu sehen.";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Gibt alle Kategorie-IDs zurück, auf die Betriebsrat Zugriff hat:
|
||||
// direkt betriebsrat_editable=1 sowie alle ihre Nachfahren im Baum (BFS nach unten).
|
||||
function get_all_betriebsrat_accessible_category_ids(): array
|
||||
{
|
||||
$seeds = [];
|
||||
$res = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT id FROM knowledgecenter_categories_update WHERE betriebsrat_editable = 1");
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$seeds[] = (int)$row['id'];
|
||||
}
|
||||
if (empty($seeds)) {
|
||||
return [];
|
||||
}
|
||||
$visited = [];
|
||||
$queue = $seeds;
|
||||
while (!empty($queue)) {
|
||||
$current = array_shift($queue);
|
||||
if (isset($visited[$current])) {
|
||||
continue;
|
||||
}
|
||||
$visited[$current] = true;
|
||||
$cRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT child_category_id FROM knowledgecenter_category_links WHERE parent_category_id = $current");
|
||||
while ($cRow = mysqli_fetch_assoc($cRes)) {
|
||||
$cid = (int)$cRow['child_category_id'];
|
||||
if (!isset($visited[$cid])) {
|
||||
$queue[] = $cid;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_keys($visited);
|
||||
}
|
||||
|
||||
// Prüft ob eine Kategorie (oder irgendein Vorfahre über knowledgecenter_category_links)
|
||||
// betriebsrat_editable=1 hat. Läuft den Baum nach oben durch.
|
||||
function is_category_betriebsrat_editable(int $categoryId): bool
|
||||
{
|
||||
$visited = [];
|
||||
$queue = [$categoryId];
|
||||
while (!empty($queue)) {
|
||||
$current = array_shift($queue);
|
||||
if (isset($visited[$current])) {
|
||||
continue;
|
||||
}
|
||||
$visited[$current] = true;
|
||||
$res = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT betriebsrat_editable FROM knowledgecenter_categories_update WHERE id = $current LIMIT 1");
|
||||
$row = mysqli_fetch_assoc($res);
|
||||
if ($row && (int)$row['betriebsrat_editable'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$pRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT parent_category_id FROM knowledgecenter_category_links WHERE child_category_id = $current");
|
||||
while ($pRow = mysqli_fetch_assoc($pRes)) {
|
||||
$pid = (int)$pRow['parent_category_id'];
|
||||
if (!isset($visited[$pid])) {
|
||||
$queue[] = $pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Switch-Case für die verschiedenen Aktionen
|
||||
$action = $_GET["action"] ?? "List";
|
||||
switch ($action) {
|
||||
case "Installation":
|
||||
require_permission('r-wiki');
|
||||
require_once(__DIR__ . '/../Installation/Installation.php');
|
||||
break;
|
||||
case "Categories":
|
||||
// Kategorieverwaltung nur für vollständige Redakteure
|
||||
if (isset($_GET['detail'])) {
|
||||
require_permission('r-wiki');
|
||||
require_once(__DIR__ . '/../Views/categories_cardform.php');
|
||||
} else {
|
||||
require_permission('r-wiki');
|
||||
require_once(__DIR__ . '/../Views/categories_listform.php');
|
||||
}
|
||||
break;
|
||||
case "PostList":
|
||||
// Betriebsrat darf die Post-Liste sehen (gefiltert auf seine Kategorien)
|
||||
require_wiki_edit_permission();
|
||||
require_once(__DIR__ . '/../Views/post_listform.php');
|
||||
break;
|
||||
case "Post":
|
||||
require_once(__DIR__ . '/../Views/post_cardform.php');
|
||||
break;
|
||||
case "Import":
|
||||
require_permission('r-wiki');
|
||||
require_once(__DIR__ . '/../Import/Import.php');
|
||||
break;
|
||||
case "NewPost":
|
||||
// Betriebsrat darf neue Posts erstellen (nur in freigegebenen Kategorien)
|
||||
require_wiki_edit_permission();
|
||||
$_GET['new'] = 1;
|
||||
require_once(__DIR__ . '/../Views/post_cardform.php');
|
||||
break;
|
||||
case "List":
|
||||
default:
|
||||
require_once(__DIR__ . '/../Views/categories_posts_listform.php');
|
||||
break;
|
||||
}
|
||||
|
||||
require_once(__DIR__ . '/../Script/Script.php');
|
||||
require_once(__DIR__ . '/../CSS/CSS.php');
|
||||
?>
|
||||
28
module/knowledgecenter_update/Dependencies/Dependencies.php
Normal file
28
module/knowledgecenter_update/Dependencies/Dependencies.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
|
||||
<script src="https://code.jquery.com/ui/1.14.1/jquery-ui.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.polyfills.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
|
||||
<!-- Quill 2.0.0‑dev.3 -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/quill@2.0.0-dev.3/dist/quill.snow.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.0-dev.3/dist/quill.min.js"></script>
|
||||
|
||||
<!-- Better Table CSS -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/quill-better-table@1.2.10/dist/quill-better-table.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Better Table JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill-better-table@1.2.10/dist/quill-better-table.min.js"></script>
|
||||
|
||||
<!-- Shared Quill helpers: Drag-Resize-Modul für Bilder.
|
||||
Aktivierung pro Editor-Instanz via `modules: { imageResize: true }`. -->
|
||||
<link rel="stylesheet" href="/module/shared_components/quill/quill_resize.css">
|
||||
<script src="/module/shared_components/quill/quill_resize.js"></script>
|
||||
<script src="/module/shared_components/quill/toolbar_presets.js"></script>
|
||||
|
||||
<script src="/mysyde/admin/admin.js"></script>
|
||||
|
||||
264
module/knowledgecenter_update/Import/Import.php
Normal file
264
module/knowledgecenter_update/Import/Import.php
Normal file
@@ -0,0 +1,264 @@
|
||||
|
||||
<h1>Import-Dashboard</h1>
|
||||
<hr>
|
||||
<a href="?action=Import&reset_import=1">Tabellen leeren (Reset) ✕</a>
|
||||
<hr>
|
||||
<a href="?action=Import&cat_import=1">Kategorien importieren ↗</a>
|
||||
<hr>
|
||||
<a href="?action=Import&post_import=1">Beiträge importieren ↗</a>
|
||||
<hr>
|
||||
|
||||
<?php
|
||||
// ini_set('display_errors', 1);
|
||||
// error_reporting(E_ALL);
|
||||
|
||||
/**
|
||||
* Funktion zum Truncaten (Leeren) aller relevanten Tabellen für den Import-Test.
|
||||
* Dabei werden vorübergehend die Foreign-Key-Prüfungen deaktiviert.
|
||||
*/
|
||||
function truncate_import_tables()
|
||||
{
|
||||
die("Die Funktion truncate_import_tables() ist deaktiviert, um Datenverlust zu vermeiden.");
|
||||
// Tabellen, die geleert werden sollen
|
||||
$tables = [
|
||||
'knowledgecenter_category_links',
|
||||
'knowledgecenter_category_post',
|
||||
'knowledgecenter_category_translations',
|
||||
'knowledgecenter_category_mandant',
|
||||
'knowledgecenter_category_department',
|
||||
'knowledgecenter_category_role',
|
||||
'knowledgecenter_categories_update',
|
||||
'knowledgecenter_post_version_contacts',
|
||||
'knowledgecenter_post_versions',
|
||||
'knowledgecenter_post_role',
|
||||
'knowledgecenter_post_files',
|
||||
'knowledgecenter_post_department',
|
||||
'knowledgecenter_post_mandant',
|
||||
'knowledgecenter_post_contacts',
|
||||
'knowledgecenter_post_comments',
|
||||
'knowledgecenter_posts'
|
||||
];
|
||||
|
||||
// Foreign-Key-Prüfungen deaktivieren
|
||||
mysqli_query($GLOBALS['mysql_con'], "SET FOREIGN_KEY_CHECKS = 0");
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$query = "TRUNCATE TABLE $table";
|
||||
if (mysqli_query($GLOBALS['mysql_con'], $query)) {
|
||||
echo "Tabelle <strong>$table</strong> wurde erfolgreich geleert.<br>";
|
||||
} else {
|
||||
echo "Fehler beim Leeren der Tabelle <strong>$table</strong>: " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign-Key-Prüfungen wieder aktivieren
|
||||
mysqli_query($GLOBALS['mysql_con'], "SET FOREIGN_KEY_CHECKS = 1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekursive Funktion zum Importieren von Kategorien.
|
||||
* Für jeden Unterordner wird – sofern noch nicht vorhanden – eine Kategorie in
|
||||
* knowledgecenter_categories_update und eine Übersetzung in knowledgecenter_category_translations (language_id = 1) angelegt.
|
||||
* Bei Unterordnern wird zudem in knowledgecenter_category_links eine Parent-Child-Verknüpfung erstellt.
|
||||
*
|
||||
* @param string $folder Absoluter Pfad des aktuellen Ordners.
|
||||
* @param int|null $parentCategoryId Falls vorhanden, die Kategorie-ID des Elternordners.
|
||||
*/
|
||||
function import_categories($folder, $parentCategoryId = null)
|
||||
{
|
||||
if (!is_dir($folder)) {
|
||||
echo "Ordner '$folder' existiert nicht.<br>";
|
||||
return;
|
||||
}
|
||||
|
||||
$folderName = basename($folder);
|
||||
$folderNameEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $folderName);
|
||||
|
||||
// Prüfen, ob eine Kategorie mit diesem Titel (language_id = 1) bereits existiert
|
||||
$query = "SELECT c.id
|
||||
FROM knowledgecenter_categories_update c
|
||||
JOIN knowledgecenter_category_translations t ON c.id = t.category_id
|
||||
WHERE t.language_id = 1 AND t.title = '$folderNameEscaped' LIMIT 1";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
if ($result && mysqli_num_rows($result) > 0) {
|
||||
$row = mysqli_fetch_assoc($result);
|
||||
$categoryId = $row['id'];
|
||||
echo "Kategorie '$folderName' existiert bereits (ID: $categoryId).<br>";
|
||||
} else {
|
||||
$sql_category = "INSERT INTO knowledgecenter_categories_update (sorting, state, modified_at, modified_by, created_at)
|
||||
VALUES (0, 1, NOW(), 'import', NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_category)) {
|
||||
echo "Fehler beim Erstellen der Kategorie '$folderName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
return;
|
||||
}
|
||||
$categoryId = mysqli_insert_id($GLOBALS['mysql_con']);
|
||||
|
||||
$sql_trans = "INSERT INTO knowledgecenter_category_translations (category_id, language_id, title, created_at)
|
||||
VALUES ($categoryId, 1, '$folderNameEscaped', NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_trans)) {
|
||||
echo "Fehler beim Einfügen der Kategorie-Übersetzung für '$folderName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
echo "Kategorie '$folderName' wurde erstellt (ID: $categoryId).<br>";
|
||||
}
|
||||
|
||||
if ($parentCategoryId !== null) {
|
||||
$query_link = "SELECT id FROM knowledgecenter_category_links WHERE parent_category_id = $parentCategoryId AND child_category_id = $categoryId LIMIT 1";
|
||||
$result_link = mysqli_query($GLOBALS['mysql_con'], $query_link);
|
||||
if ($result_link && mysqli_num_rows($result_link) == 0) {
|
||||
$sql_link = "INSERT INTO knowledgecenter_category_links (parent_category_id, child_category_id, sorting, created_at)
|
||||
VALUES ($parentCategoryId, $categoryId, 0, NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_link)) {
|
||||
echo "Fehler beim Verknüpfen der Kategorie '$folderName' mit Eltern ($parentCategoryId): " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unterordner verarbeiten
|
||||
$subDirs = glob($folder . "/*", GLOB_ONLYDIR);
|
||||
if ($subDirs !== false) {
|
||||
foreach ($subDirs as $subDir) {
|
||||
import_categories($subDir, $categoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekursive Funktion zum Importieren von Beiträgen.
|
||||
* In jedem Ordner werden alle Dateien als Beiträge importiert.
|
||||
* Für jede Datei wird der Dateiname (ohne Erweiterung) als Titel verwendet und
|
||||
* als Teaser "Hier finden Sie die Informationen zu [Dateiname]" gesetzt.
|
||||
* Anschließend wird der Beitrag in knowledgecenter_posts und knowledgecenter_post_versions (language_id = 1, Version 1) angelegt
|
||||
* und in knowledgecenter_category_post mit der zugehörigen Kategorie verknüpft.
|
||||
* Zusätzlich wird ein Eintrag in knowledgecenter_post_files für die Datei erstellt.
|
||||
*
|
||||
* @param string $folder Absoluter Pfad des aktuellen Ordners.
|
||||
*/
|
||||
function import_posts($folder)
|
||||
{
|
||||
if (!is_dir($folder)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ermitteln der zugehörigen Kategorie anhand des Ordnernamens
|
||||
$folderName = basename($folder);
|
||||
$folderNameEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $folderName);
|
||||
$query = "SELECT c.id
|
||||
FROM knowledgecenter_categories_update c
|
||||
JOIN knowledgecenter_category_translations t ON c.id = t.category_id
|
||||
WHERE t.language_id = 1 AND t.title = '$folderNameEscaped' LIMIT 1";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
if ($result && mysqli_num_rows($result) > 0) {
|
||||
$row = mysqli_fetch_assoc($result);
|
||||
$categoryId = $row['id'];
|
||||
} else {
|
||||
echo "Keine Kategorie für '$folderName' gefunden. Bitte zuerst Kategorien importieren.<br>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Alle Dateien im aktuellen Ordner als Beiträge importieren
|
||||
$files = glob($folder . "/*");
|
||||
if ($files !== false) {
|
||||
foreach ($files as $item) {
|
||||
if (is_file($item)) {
|
||||
$fileName = basename($item);
|
||||
$fileTitle = pathinfo($fileName, PATHINFO_FILENAME); // Dateiname ohne Erweiterung
|
||||
$titleEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $fileTitle);
|
||||
$teaser = "Hier finden Sie die Informationen zu $fileTitle";
|
||||
$teaserEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $teaser);
|
||||
|
||||
// Überprüfen, ob bereits ein Beitrag mit diesem Titel existiert
|
||||
$checkQuery = "SELECT pv.id
|
||||
FROM knowledgecenter_post_versions pv
|
||||
JOIN knowledgecenter_posts p ON pv.post_id = p.id
|
||||
WHERE pv.language_id = 1 AND pv.title = '$titleEscaped' LIMIT 1";
|
||||
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkQuery);
|
||||
if ($checkResult && mysqli_num_rows($checkResult) > 0) {
|
||||
echo "Beitrag '$fileTitle' existiert bereits. Überspringe.<br>";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Beitrag erstellen
|
||||
$sql_post = "INSERT INTO knowledgecenter_posts (created_at, modified_at) VALUES (NOW(), NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_post)) {
|
||||
echo "Fehler beim Erstellen des Beitrags für Datei '$fileName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
continue;
|
||||
}
|
||||
$newPostId = mysqli_insert_id($GLOBALS['mysql_con']);
|
||||
|
||||
// Beitrag-Version anlegen (language_id = 1, Version 1)
|
||||
$sql_post_version = "INSERT INTO knowledgecenter_post_versions
|
||||
(post_id, version_number, language_id, title, teaser, content, author_id, created_at)
|
||||
VALUES ($newPostId, 1, 1, '$titleEscaped', '$teaserEscaped', '', 0, NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_post_version)) {
|
||||
echo "Fehler beim Erstellen der Beitragsversion für Datei '$fileTitle': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
|
||||
// Beitrag mit der Kategorie verknüpfen
|
||||
$sql_link_post = "INSERT INTO knowledgecenter_category_post (category_id, post_id) VALUES ($categoryId, $newPostId)";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_link_post)) {
|
||||
echo "Fehler beim Verknüpfen des Beitrags $newPostId mit Kategorie $categoryId: " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
|
||||
// Eintrag in die Files-Tabelle erstellen
|
||||
// Ermitteln des relativen Pfads relativ zum Document Root:
|
||||
$docRoot = realpath($_SERVER['DOCUMENT_ROOT']);
|
||||
$relativePath = str_replace($docRoot, '', realpath($item));
|
||||
// Als Beschreibung verwenden wir den Dateinamen (inklusive Erweiterung)
|
||||
$descEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $fileName);
|
||||
$pathEscaped = mysqli_real_escape_string($GLOBALS['mysql_con'], $relativePath);
|
||||
$sql_file = "INSERT INTO knowledgecenter_post_files (post_id, description, path, created_at)
|
||||
VALUES ($newPostId, '$descEscaped', '$pathEscaped', NOW())";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sql_file)) {
|
||||
echo "Fehler beim Einfügen in die Files-Tabelle für Datei '$fileName': " . mysqli_error($GLOBALS['mysql_con']) . "<br>";
|
||||
}
|
||||
|
||||
echo "Beitrag '$fileTitle' (ID: $newPostId) in Kategorie '$folderName' importiert.<br>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unterordner rekursiv verarbeiten
|
||||
$subDirs = glob($folder . "/*", GLOB_ONLYDIR);
|
||||
if ($subDirs !== false) {
|
||||
foreach ($subDirs as $subDir) {
|
||||
import_posts($subDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Hauptlogik: Prüfe die GET-Parameter zur Steuerung der Aktionen
|
||||
// Da im Link auch ?action=Import gesetzt ist, können wir dies ignorieren und nur reset, cat_import, post_import prüfen.
|
||||
|
||||
// Reset: Tabellen leeren
|
||||
if (isset($_GET['reset_import']) && $_GET['reset_import'] == "1") {
|
||||
echo "<h1>Reset Import gestartet</h1>";
|
||||
truncate_import_tables();
|
||||
echo "<h1>Reset Import beendet</h1>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Kategorien importieren: GET-Parameter "cat_import" verwenden
|
||||
if (isset($_GET['cat_import']) && $_GET['cat_import'] == "1") {
|
||||
$importFolder = realpath($_SERVER['DOCUMENT_ROOT'] . '/userdata/knowledgecenter_update');
|
||||
if (!$importFolder) {
|
||||
die("Der Import-Ordner wurde nicht gefunden.");
|
||||
}
|
||||
echo "<h1>Kategorien-Import gestartet</h1>";
|
||||
import_categories($importFolder);
|
||||
echo "<h1>Kategorien-Import beendet</h1>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beiträge importieren: GET-Parameter "post_import" verwenden
|
||||
if (isset($_GET['post_import']) && $_GET['post_import'] == "1") {
|
||||
$importFolder = realpath($_SERVER['DOCUMENT_ROOT'] . '/userdata/knowledgecenter_update');
|
||||
if (!$importFolder) {
|
||||
die("Der Import-Ordner wurde nicht gefunden.");
|
||||
}
|
||||
echo "<h1>Beiträge-Import gestartet</h1>";
|
||||
import_posts($importFolder);
|
||||
echo "<h1>Beiträge-Import beendet</h1>";
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
444
module/knowledgecenter_update/Installation/Installation.php
Normal file
444
module/knowledgecenter_update/Installation/Installation.php
Normal file
@@ -0,0 +1,444 @@
|
||||
<?php
|
||||
|
||||
function echoSuccess($message)
|
||||
{
|
||||
echo "<div style='background-color:#e6ffed;color:#267b3c;padding:15px;
|
||||
border:1px solid #b2dfdb;border-radius:5px;margin:20px;
|
||||
font-family:sans-serif;'>✅ $message</div>";
|
||||
}
|
||||
|
||||
function echoError($message)
|
||||
{
|
||||
echo "<div style='background-color:#ffe6e6;color:#a33;padding:15px;
|
||||
border:1px solid #f5c2c2;border-radius:5px;margin:20px;
|
||||
font-family:sans-serif;'>❌ $message</div>";
|
||||
}
|
||||
|
||||
// Beispielhafte DB-Verbindung (nur Dummy für Kontext)
|
||||
if (!isset($GLOBALS['mysql_con'])) {
|
||||
echoError("Keine Datenbankverbindung gefunden.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Tabellen-Erstellung
|
||||
function create_tables()
|
||||
{
|
||||
$allSuccess = true;
|
||||
|
||||
$queries = [
|
||||
// Tabellenname => SQL
|
||||
"knowledgecenter_posts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by VARCHAR(255) COMMENT 'Name des Main Contact',
|
||||
preview_inline TINYINT(1) NOT NULL DEFAULT 1 COMMENT '0 = keine Inline-Vorschau, 1 = Inline-Vorschau',
|
||||
background_color VARCHAR(9) NOT NULL DEFAULT '#FFFFFF',
|
||||
text_color VARCHAR(9) NOT NULL DEFAULT '#000000'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_contacts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_contacts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
main_contact_id INT NOT NULL,
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_mandant" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_mandant (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
mandant_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_department" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_department (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_files" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
description VARCHAR(255),
|
||||
path VARCHAR(500),
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_role" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_role (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_versions" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
language_id INT NOT NULL,
|
||||
title VARCHAR(255),
|
||||
image VARCHAR(255),
|
||||
teaser TEXT,
|
||||
content LONGTEXT,
|
||||
author_id INT,
|
||||
state TINYINT DEFAULT 1,
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
valid_from DATETIME NULL,
|
||||
valid_until DATETIME NULL,
|
||||
background_color VARCHAR(9) NOT NULL DEFAULT '#FFFFFF',
|
||||
text_color VARCHAR(9) NOT NULL DEFAULT '#000000',
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_version_contacts" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_version_contacts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version_id INT NOT NULL,
|
||||
contact_id INT NOT NULL,
|
||||
FOREIGN KEY (version_id) REFERENCES knowledgecenter_post_versions(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_categories_update" => "CREATE TABLE IF NOT EXISTS knowledgecenter_categories_update (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sorting INT DEFAULT 0,
|
||||
state TINYINT DEFAULT 1,
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by VARCHAR(255) COMMENT 'Name des Main Contact',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_translations" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_translations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
language_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
image VARCHAR(255),
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_post" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_post (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
post_id INT NOT NULL,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_mandant" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_mandant (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
mandant_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_department" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_department (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_role" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_role (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_post_comments" => "CREATE TABLE IF NOT EXISTS knowledgecenter_post_comments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
parent_id INT DEFAULT NULL,
|
||||
text TEXT NOT NULL,
|
||||
author_id INT NOT NULL COMMENT 'main_contact_id',
|
||||
modified_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES knowledgecenter_posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_id) REFERENCES knowledgecenter_post_comments(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
|
||||
|
||||
"knowledgecenter_category_links" => "CREATE TABLE IF NOT EXISTS knowledgecenter_category_links (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
parent_category_id INT NOT NULL,
|
||||
child_category_id INT NOT NULL,
|
||||
sorting INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_category_id) REFERENCES knowledgecenter_categories_update(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
|
||||
];
|
||||
|
||||
foreach ($queries as $name => $sql) {
|
||||
if (mysqli_query($GLOBALS['mysql_con'], $sql)) {
|
||||
echoSuccess("Tabelle <strong>$name</strong> wurde erfolgreich erstellt (oder existierte bereits).");
|
||||
} else {
|
||||
echoError("Fehler beim Erstellen der Tabelle <strong>$name</strong>: " . mysqli_error($GLOBALS['mysql_con']));
|
||||
$allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $allSuccess;
|
||||
}
|
||||
|
||||
// Ausführen der Tabellen-Erstellung
|
||||
if (create_tables()) {
|
||||
echoSuccess("✅ Alle Tabellen erfolgreich erstellt!");
|
||||
} else {
|
||||
echoError("❌ Es gab Fehler beim Erstellen einzelner Tabellen.");
|
||||
}
|
||||
|
||||
// Jetzt die zusätzlichen Indexe mittels ALTER TABLE setzen
|
||||
$indexQueries = [
|
||||
// knowledgecenter_posts
|
||||
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_created_at (created_at);",
|
||||
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_modified_at (modified_at);",
|
||||
"ALTER TABLE knowledgecenter_posts ADD INDEX idx_modified_by (modified_by);",
|
||||
|
||||
// knowledgecenter_post_contacts
|
||||
"ALTER TABLE knowledgecenter_post_contacts ADD INDEX idx_post_id (post_id);",
|
||||
"ALTER TABLE knowledgecenter_post_contacts ADD INDEX idx_main_contact_id (main_contact_id);",
|
||||
|
||||
// knowledgecenter_post_mandant
|
||||
"ALTER TABLE knowledgecenter_post_mandant ADD INDEX idx_post_mandant (post_id, mandant_id);",
|
||||
|
||||
// knowledgecenter_post_department
|
||||
"ALTER TABLE knowledgecenter_post_department ADD INDEX idx_post_department (post_id, department_id);",
|
||||
|
||||
// knowledgecenter_post_files
|
||||
"ALTER TABLE knowledgecenter_post_files ADD INDEX idx_post_id (post_id);",
|
||||
|
||||
// knowledgecenter_post_role
|
||||
"ALTER TABLE knowledgecenter_post_role ADD INDEX idx_post_role (post_id, role_id);",
|
||||
|
||||
// knowledgecenter_post_versions
|
||||
"ALTER TABLE knowledgecenter_post_versions ADD INDEX idx_post_id (post_id);",
|
||||
"ALTER TABLE knowledgecenter_post_versions ADD INDEX idx_post_language (post_id, language_id);",
|
||||
|
||||
// knowledgecenter_post_version_contacts
|
||||
"ALTER TABLE knowledgecenter_post_version_contacts ADD INDEX idx_version_contact (version_id, contact_id);",
|
||||
|
||||
// knowledgecenter_categories_update
|
||||
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_state_sorting (state, sorting);",
|
||||
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_modified_at (modified_at);",
|
||||
"ALTER TABLE knowledgecenter_categories_update ADD INDEX idx_modified_by (modified_by);",
|
||||
|
||||
// knowledgecenter_category_translations
|
||||
"ALTER TABLE knowledgecenter_category_translations ADD INDEX idx_category_language (category_id, language_id);",
|
||||
|
||||
// knowledgecenter_category_post
|
||||
"ALTER TABLE knowledgecenter_category_post ADD INDEX idx_category_post (category_id, post_id);",
|
||||
|
||||
// knowledgecenter_category_mandant
|
||||
"ALTER TABLE knowledgecenter_category_mandant ADD INDEX idx_category_mandant (category_id, mandant_id);",
|
||||
|
||||
// knowledgecenter_category_department
|
||||
"ALTER TABLE knowledgecenter_category_department ADD INDEX idx_category_department (category_id, department_id);",
|
||||
|
||||
// knowledgecenter_category_role
|
||||
"ALTER TABLE knowledgecenter_category_role ADD INDEX idx_category_role (category_id, role_id);",
|
||||
|
||||
// knowledgecenter_post_comments
|
||||
"ALTER TABLE knowledgecenter_post_comments ADD INDEX idx_post_id (post_id);",
|
||||
"ALTER TABLE knowledgecenter_post_comments ADD INDEX idx_parent_id (parent_id);",
|
||||
|
||||
// knowledgecenter_category_links
|
||||
"ALTER TABLE knowledgecenter_category_links ADD INDEX idx_parent_child (parent_category_id, child_category_id);"
|
||||
];
|
||||
|
||||
foreach ($indexQueries as $query) {
|
||||
if (mysqli_query($GLOBALS['mysql_con'], $query)) {
|
||||
echoSuccess("Index erfolgreich erstellt: " . htmlspecialchars($query));
|
||||
} else {
|
||||
echoError("Fehler beim Erstellen des Index: " . mysqli_error($GLOBALS['mysql_con']) . " - Query: " . htmlspecialchars($query));
|
||||
}
|
||||
}
|
||||
|
||||
// Überprüfen, ob die Spalte "post_id" in der Tabelle "main_certificate" existiert
|
||||
$checkQuery = "SHOW COLUMNS FROM main_certificate LIKE 'post_id'";
|
||||
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkQuery);
|
||||
if ($checkResult && mysqli_num_rows($checkResult) == 0) {
|
||||
// Spalte existiert nicht – hinzufügen
|
||||
$alterQuery = "ALTER TABLE main_certificate ADD COLUMN post_id INT NOT NULL"; // oder NULL, je nach Anforderung
|
||||
if (mysqli_query($GLOBALS['mysql_con'], $alterQuery)) {
|
||||
echoSuccess("Spalte 'post_id' in Tabelle main_certificate erfolgreich hinzugefügt.");
|
||||
} else {
|
||||
echoError("Fehler beim Hinzufügen der Spalte 'post_id': " . mysqli_error($GLOBALS['mysql_con']));
|
||||
}
|
||||
} else {
|
||||
echoSuccess("Spalte 'post_id' existiert bereits in main_certificate.");
|
||||
}
|
||||
|
||||
// Array mit den Permissions, die hinzugefügt werden sollen
|
||||
$permissions = [
|
||||
[
|
||||
'description' => 'Darf Wiki Modul installieren',
|
||||
'code' => 'install_admin_kc',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Inhalte importieren',
|
||||
'code' => 'import_admin_kc',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitragsliste sehen',
|
||||
'code' => 'view_admin_kc_post_list',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Kategorie Admin Listen Items löschen',
|
||||
'code' => 'delete_admin_kc_category_item',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Kategorie Admin Liste sehen',
|
||||
'code' => 'view_admin_kc_category_list',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Kategorien erstellen',
|
||||
'code' => 'create_admin_kc_category',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Kategorien bearbeiten',
|
||||
'code' => 'edit_admin_kc_category',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf neuen Wiki Beitrag erstellen',
|
||||
'code' => 'create_admin_kc_post',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitragsversion ändern',
|
||||
'code' => 'edit_admin_kc_post_version',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf neue Wiki Beitragsversion erstellen',
|
||||
'code' => 'create_admin_kc_post_version',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitragsversion löschen',
|
||||
'code' => 'delete_admin_kc_post_version',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitragsdateien löschen',
|
||||
'code' => 'delete_admin_kc_post_file',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitragsdateien hinzufügen',
|
||||
'code' => 'add_admin_kc_post_file',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitrags Ansprechpartner verwalten',
|
||||
'code' => 'manage_admin_kc_post_contacts',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitrags Einstellungen sehen',
|
||||
'code' => 'view_admin_kc_post_settings',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
],
|
||||
[
|
||||
'description' => 'Darf Wiki Beitrags Einstellungen speichern',
|
||||
'code' => 'save_admin_kc_post_settings',
|
||||
'short_text' => '',
|
||||
'only_main_mandant' => 1,
|
||||
'default_active' => 0
|
||||
]
|
||||
];
|
||||
|
||||
// Iteriere über das Array und füge jede Permission ein:
|
||||
foreach ($permissions as $perm) {
|
||||
// Stelle sicher, dass Zeichenketten richtig escaped werden:
|
||||
$description = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['description']);
|
||||
$code = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['code']);
|
||||
$short_text = mysqli_real_escape_string($GLOBALS['mysql_con'], $perm['short_text']);
|
||||
$only_main_mandant = (int) $perm['only_main_mandant'];
|
||||
$default_active = (int) $perm['default_active'];
|
||||
|
||||
// Zuerst prüfen, ob eine Permission mit diesem Code bereits existiert.
|
||||
$checkSql = "SELECT id FROM main_permission WHERE code = '$code' LIMIT 1";
|
||||
$checkResult = mysqli_query($GLOBALS['mysql_con'], $checkSql);
|
||||
if ($checkResult && mysqli_num_rows($checkResult) > 0) {
|
||||
// Falls vorhanden, kannst Du eine Erfolgsmeldung anzeigen oder den Eintrag überspringen.
|
||||
echoSuccess("Permission '$description' existiert bereits.");
|
||||
continue; // Weitere Verarbeitung überspringen.
|
||||
}
|
||||
|
||||
// Falls nicht vorhanden, führe das INSERT aus:
|
||||
$insertSql = "INSERT INTO `main_permission` (`id`, `description`, `code`, `short_text`, `only_main_mandant`, `default_active`)
|
||||
VALUES (NULL, '$description', '$code', '$short_text', '$only_main_mandant', '$default_active')";
|
||||
if (mysqli_query($GLOBALS['mysql_con'], $insertSql)) {
|
||||
echoSuccess("Permission '$description' wurde erfolgreich hinzugefügt.");
|
||||
} else {
|
||||
echoError("Fehler beim Hinzufügen der Permission '$description': " . mysqli_error($GLOBALS['mysql_con']));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
// Einmalige Migration: legt in jedem bestehenden Beitragsordner unter
|
||||
// /userdata/knowledgecenter_update/posts/<hash>/ einen Symlink "shared"
|
||||
// an, der auf ../../shared zeigt.
|
||||
//
|
||||
// Idempotent (bereits vorhandene Einträge werden übersprungen) und
|
||||
// CLI-only — niemals direkt per HTTP aufrufbar.
|
||||
//
|
||||
// Usage:
|
||||
// php Installation/add_shared_symlinks.php --dry-run
|
||||
// php Installation/add_shared_symlinks.php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('CLI only');
|
||||
}
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv, true);
|
||||
|
||||
$postsDir = realpath(__DIR__ . '/../../../userdata/knowledgecenter_update/posts');
|
||||
$sharedDir = realpath(__DIR__ . '/../../../userdata/knowledgecenter_update/shared');
|
||||
|
||||
if ($postsDir === false) {
|
||||
fwrite(STDERR, "FEHLER: posts/-Verzeichnis nicht gefunden\n");
|
||||
exit(2);
|
||||
}
|
||||
if ($sharedDir === false) {
|
||||
fwrite(STDERR, "FEHLER: shared/-Verzeichnis nicht gefunden — bitte erst anlegen\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
echo "Mode: " . ($dryRun ? 'DRY-RUN (keine Änderungen)' : 'LIVE') . "\n";
|
||||
echo "Posts: $postsDir\n";
|
||||
echo "Shared: $sharedDir\n\n";
|
||||
|
||||
$dirs = glob($postsDir . '/*', GLOB_ONLYDIR);
|
||||
if ($dirs === false) {
|
||||
fwrite(STDERR, "FEHLER: glob() lieferte false\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$collisions = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$linkPath = $dir . '/shared';
|
||||
$name = basename($dir);
|
||||
|
||||
if (is_link($linkPath)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if (file_exists($linkPath)) {
|
||||
// gleichnamiger echter Ordner/Datei — nicht überschreiben
|
||||
echo "[KOLLISION] $name/shared existiert bereits als Ordner/Datei — übersprungen\n";
|
||||
$collisions++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
echo "[DRY-RUN] würde anlegen: $name/shared -> ../../shared\n";
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (@symlink('../../shared', $linkPath)) {
|
||||
$created++;
|
||||
} else {
|
||||
$err = error_get_last();
|
||||
echo "[FEHLER] $name/shared konnte nicht angelegt werden: " . ($err['message'] ?? 'unbekannt') . "\n";
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Zusammenfassung ===\n";
|
||||
echo "Beitragsordner gesamt: " . count($dirs) . "\n";
|
||||
echo "Symlinks " . ($dryRun ? 'geplant' : 'angelegt') . ": $created\n";
|
||||
echo "Bereits vorhanden: $skipped\n";
|
||||
echo "Kollisionen: $collisions\n";
|
||||
echo "Fehlgeschlagen: $failed\n";
|
||||
|
||||
exit($failed > 0 ? 1 : 0);
|
||||
62
module/knowledgecenter_update/Middleware/Middleware.php
Normal file
62
module/knowledgecenter_update/Middleware/Middleware.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Zunächst: Holen der User-Daten (Mandanten, Abteilungen, Rollen, Einrichtungen, Fachbereiche)
|
||||
*/
|
||||
$currentUserId = $GLOBALS["main_contact"]["id"] ?? 0;
|
||||
$userMandants = [];
|
||||
$userDepartments = [];
|
||||
$userRoles = [];
|
||||
$userEinrichts = [];
|
||||
$userFachbereiche = [];
|
||||
|
||||
if ($currentUserId) {
|
||||
$query = "SELECT main_mandant_id, main_department_id, main_role_id, main_einricht_id, main_bereich_id
|
||||
FROM main_contact_department
|
||||
WHERE main_contact_id = $currentUserId";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
// Filtere 0 aus – 0 gilt als "keine Zuordnung"
|
||||
if ($row['main_mandant_id'] != 0 && !in_array($row['main_mandant_id'], $userMandants)) {
|
||||
$userMandants[] = $row['main_mandant_id'];
|
||||
}
|
||||
if ($row['main_department_id'] != 0 && !in_array($row['main_department_id'], $userDepartments)) {
|
||||
$userDepartments[] = $row['main_department_id'];
|
||||
}
|
||||
if ($row['main_role_id'] != 0 && !in_array($row['main_role_id'], $userRoles)) {
|
||||
$userRoles[] = $row['main_role_id'];
|
||||
}
|
||||
if ($row['main_einricht_id'] != 0 && !in_array($row['main_einricht_id'], $userEinrichts)) {
|
||||
$userEinrichts[] = $row['main_einricht_id'];
|
||||
}
|
||||
if ($row['main_bereich_id'] != 0 && !in_array($row['main_bereich_id'], $userFachbereiche)) {
|
||||
$userFachbereiche[] = $row['main_bereich_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Für den LEFT JOIN-Ansatz bereiten wir die erlaubten Werte als
|
||||
* kommaseparierte Strings vor.
|
||||
*/
|
||||
$allowedMandants = !empty($userMandants) ? implode(',', $userMandants) : 'NULL';
|
||||
$allowedDepartments = !empty($userDepartments) ? implode(',', $userDepartments) : 'NULL';
|
||||
$allowedRoles = !empty($userRoles) ? implode(',', $userRoles) : 'NULL';
|
||||
$allowedEinrichts = !empty($userEinrichts) ? implode(',', $userEinrichts) : 'NULL';
|
||||
$allowedFachbereiche = !empty($userFachbereiche) ? implode(',', $userFachbereiche) : 'NULL';
|
||||
|
||||
// Für den globalen Zugriff kannst Du auch die Variablen in $GLOBALS speichern:
|
||||
$GLOBALS['allowedMandants'] = $allowedMandants;
|
||||
$GLOBALS['allowedDepartments'] = $allowedDepartments;
|
||||
$GLOBALS['allowedRoles'] = $allowedRoles;
|
||||
$GLOBALS['allowedEinrichts'] = $allowedEinrichts;
|
||||
$GLOBALS['allowedFachbereiche'] = $allowedFachbereiche;
|
||||
|
||||
|
||||
|
||||
|
||||
// echo "<pre>";
|
||||
// echo "Mandanten: " . $allowedMandants . "\n";
|
||||
// echo "Abteilungen: " . $allowedDepartments . "\n";
|
||||
// echo "Rollen: " . $allowedRoles . "\n";
|
||||
// echo "</pre>";
|
||||
0
module/knowledgecenter_update/Plugins/_files/cache/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/folders/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/folders/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/images/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/images/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/menu/.gitkeep
vendored
Normal file
0
module/knowledgecenter_update/Plugins/_files/cache/menu/.gitkeep
vendored
Normal file
228
module/knowledgecenter_update/Plugins/_files/js/custom.js
Normal file
228
module/knowledgecenter_update/Plugins/_files/js/custom.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// custom.js – Files Gallery Selection & Popup Script
|
||||
|
||||
// Prüfen, ob dieses Fenster ein Popup (d.h. ein Child-Fenster) ist
|
||||
if (window.opener) {
|
||||
// URL-Parameter auslesen und in localStorage speichern
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let input_id = urlParams.get('input_id') || "";
|
||||
let image_id = urlParams.get('image_id') || "";
|
||||
let type = urlParams.get('type') || "";
|
||||
let mode = urlParams.get('mode') || "";
|
||||
let saveajax = urlParams.get('saveajax') || "";
|
||||
let kc_lock_path = urlParams.get('kc_lock_path') || "";
|
||||
|
||||
if (urlParams.has('input_id')) {
|
||||
if (input_id) {
|
||||
localStorage.setItem('input_id', input_id);
|
||||
} else {
|
||||
localStorage.removeItem('input_id');
|
||||
}
|
||||
}
|
||||
if (urlParams.has('image_id')) {
|
||||
if (image_id) {
|
||||
localStorage.setItem('image_id', image_id);
|
||||
} else {
|
||||
localStorage.removeItem('image_id');
|
||||
}
|
||||
}
|
||||
if (urlParams.has('type')) {
|
||||
if (type) {
|
||||
localStorage.setItem('type', type);
|
||||
} else {
|
||||
localStorage.removeItem('type');
|
||||
}
|
||||
}
|
||||
if (urlParams.has('mode')) {
|
||||
if (mode) {
|
||||
localStorage.setItem('mode', mode);
|
||||
} else {
|
||||
localStorage.removeItem('mode');
|
||||
}
|
||||
}
|
||||
if (urlParams.has('saveajax')) {
|
||||
if (saveajax) {
|
||||
localStorage.setItem('saveajax', saveajax);
|
||||
} else {
|
||||
localStorage.removeItem('saveajax');
|
||||
}
|
||||
}
|
||||
if (urlParams.has('kc_lock_path')) {
|
||||
if (kc_lock_path) {
|
||||
localStorage.setItem('kc_lock_path', kc_lock_path);
|
||||
} else {
|
||||
localStorage.removeItem('kc_lock_path');
|
||||
}
|
||||
}
|
||||
|
||||
// Werte aus localStorage wieder auslesen
|
||||
input_id = localStorage.getItem('input_id');
|
||||
image_id = localStorage.getItem('image_id');
|
||||
type = localStorage.getItem('type');
|
||||
mode = localStorage.getItem('mode');
|
||||
saveajax = localStorage.getItem('saveajax');
|
||||
kc_lock_path = localStorage.getItem('kc_lock_path') || "";
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
|
||||
const cleaned = String(path).replace(/^\/+/, '');
|
||||
const lockPath = String(kc_lock_path || '').replace(/^\/+|\/+$/g, '');
|
||||
|
||||
if (!lockPath) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
if (cleaned === lockPath || cleaned.indexOf(lockPath + '/') === 0) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return lockPath + '/' + cleaned;
|
||||
}
|
||||
|
||||
function normalizePaths(paths) {
|
||||
if (!paths) return "";
|
||||
|
||||
return String(paths)
|
||||
.split(',')
|
||||
.map(part => normalizePath(part.trim()))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Übergibt die gewählten Pfade an das Parent-Fenster und schließt das Popup.
|
||||
* @param {string} paths Kommagetrennter Pfad(e)
|
||||
* @param {string} input_id ID des Input-Felds im Parent
|
||||
* @param {string} image_id ID des Bild-Elements im Parent
|
||||
*/
|
||||
function sendDataAndClose(paths, input_id, image_id) {
|
||||
const normalizedPaths = normalizePaths(paths);
|
||||
console.log("Übergebe Pfade:", normalizedPaths);
|
||||
if (!window.opener) return;
|
||||
|
||||
// Setze den Pfad oder die Pfade im Input-Feld
|
||||
const input = window.opener.document.getElementById(input_id);
|
||||
if (input) {
|
||||
input.value = normalizedPaths;
|
||||
console.log(`Wert in Input "${input_id}" gesetzt.`);
|
||||
} else {
|
||||
console.error(`Kein Input-Feld mit der ID "${input_id}" gefunden.`);
|
||||
}
|
||||
|
||||
// Bild-Vorschau aktualisieren (sucht erstes Element mit Klasse "preview")
|
||||
const img = window.opener.document.querySelector('.preview');
|
||||
if (img) {
|
||||
img.src = '/userdata/' + normalizedPaths.split(',')[0].trim();
|
||||
img.style.display = "block";
|
||||
console.log('Bild-Element mit Klasse "preview" aktualisiert.');
|
||||
} else {
|
||||
console.warn('Kein Bild-Element mit der Klasse "preview" gefunden.');
|
||||
}
|
||||
|
||||
// Optional: übergebene AJAX-Funktion aufrufen
|
||||
if (saveajax && typeof window.opener[saveajax] === 'function') {
|
||||
console.log(`Rufe AJAX-Funktion "${saveajax}" im Parent auf.`);
|
||||
window.opener[saveajax]();
|
||||
} else if (saveajax) {
|
||||
console.warn(`AJAX-Funktion "${saveajax}" nicht gefunden.`);
|
||||
}
|
||||
|
||||
// Popup schließen
|
||||
console.log("Popup wird geschlossen.");
|
||||
window.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt den Button "Auswahl speichern" in der Toolbar ein (einmalig).
|
||||
*/
|
||||
function addCustomButton() {
|
||||
const topbar = document.querySelector('.topbar-select');
|
||||
if (!topbar) return;
|
||||
const buttonsContainer = topbar.querySelector('.buttons-selected');
|
||||
if (!buttonsContainer) return;
|
||||
if (buttonsContainer.querySelector('.my-custom-button')) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Auswahl speichern';
|
||||
button.className = 'my-custom-button';
|
||||
Object.assign(button.style, {
|
||||
marginRight: '10px',
|
||||
padding: '10px 20px',
|
||||
backgroundColor: 'rgb(94 188 24)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
cursor: 'pointer'
|
||||
});
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const selectedItems = document.querySelectorAll('a.files-a[data-selected="1"]');
|
||||
const selectedPaths = Array.from(selectedItems).map(el => el.dataset.path);
|
||||
const pathString = selectedPaths.join(', ');
|
||||
console.log('Ausgewählte Pfade:', selectedPaths);
|
||||
sendDataAndClose(pathString, input_id, image_id);
|
||||
});
|
||||
|
||||
buttonsContainer.insertBefore(button, buttonsContainer.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob mindestens ein Element ausgewählt ist, und zeigt ggf. den Button.
|
||||
*/
|
||||
function checkSelectionAndAddButton() {
|
||||
const anySelected = document.querySelector('a.files-a[data-selected="1"]');
|
||||
if (anySelected) {
|
||||
addCustomButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Observer für Änderungen an `data-selected`
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (
|
||||
mutation.type === 'attributes' &&
|
||||
mutation.attributeName === 'data-selected'
|
||||
) {
|
||||
const target = mutation.target;
|
||||
const isSelected = target.getAttribute('data-selected') === '1';
|
||||
|
||||
// Wenn single → andere Auswahl(en) aufheben
|
||||
if (isSelected && mode === 'single') {
|
||||
document.querySelectorAll('a.files-a[data-selected="1"]').forEach(el => {
|
||||
if (el !== target) {
|
||||
el.setAttribute('data-selected', '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkSelectionAndAddButton();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Beginnt, alle Gallery-Items auf Auswahländerungen zu beobachten.
|
||||
*/
|
||||
function observeFiles() {
|
||||
const items = document.querySelectorAll('a.files-a');
|
||||
items.forEach(item => {
|
||||
observer.observe(item, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-selected']
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialisierung nach DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
observeFiles();
|
||||
|
||||
// Falls neue Items nachgeladen werden, erneut beobachten
|
||||
const listObserver = new MutationObserver(() => {
|
||||
observeFiles();
|
||||
});
|
||||
listObserver.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
}
|
||||
3552
module/knowledgecenter_update/Plugins/filesgallery.php
Normal file
3552
module/knowledgecenter_update/Plugins/filesgallery.php
Normal file
File diff suppressed because it is too large
Load Diff
126
module/knowledgecenter_update/Script/Script.php
Normal file
126
module/knowledgecenter_update/Script/Script.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<script>
|
||||
// Redakteur (r-wiki) darf frei navigieren; Betriebsrat ist auf den Post-Ordner beschränkt
|
||||
var kcUserLockedToPostFolder = <?= (!is_wiki_redakteur() && is_wiki_betriebsrat()) ? 'true' : 'false' ?>;
|
||||
|
||||
function buildFileGalleryUrl(target, mode, ajaxFunction, nocache) {
|
||||
var base = "/module/knowledgecenter_update/Plugins/filesgallery.php?";
|
||||
var postPath = (typeof window.kcPostGalleryPath === "string")
|
||||
? window.kcPostGalleryPath.replace(/^\/+|\/+$/g, "")
|
||||
: "";
|
||||
|
||||
// Redakteur: kein Lock (leerer kc_lock_path löscht den Session-Lock)
|
||||
// Betriebsrat: immer auf Post-Ordner beschränken
|
||||
var lockPath = kcUserLockedToPostFolder ? postPath : "";
|
||||
|
||||
var params = "input_id=" + encodeURIComponent(target) +
|
||||
"&mode=" + encodeURIComponent(mode) +
|
||||
"&saveajax=" + encodeURIComponent(ajaxFunction || "") +
|
||||
"&kc_lock_path=" + encodeURIComponent(lockPath) +
|
||||
"&nocache=" + nocache;
|
||||
|
||||
// Startverzeichnis: bei Betriebsrat immer Post-Ordner, bei Redakteur auch
|
||||
var startPath = postPath;
|
||||
if (startPath.length > 0) {
|
||||
return base + encodeURIComponent(startPath) + "&" + params;
|
||||
}
|
||||
|
||||
return base + params;
|
||||
}
|
||||
|
||||
function showMessage(message, isSuccess) {
|
||||
var $msg = $("#message");
|
||||
|
||||
// Vorherige Animationen stoppen und Opazität zurücksetzen
|
||||
$msg.stop(true, true).css("opacity", 1);
|
||||
|
||||
// Styling je nach Erfolg oder Fehler
|
||||
if (isSuccess) {
|
||||
$msg.css({
|
||||
"color": "green",
|
||||
"position": "relative",
|
||||
"background-color": "#d4edda",
|
||||
"margin-bottom": "10px",
|
||||
"border": "1px solid green"
|
||||
});
|
||||
} else {
|
||||
$msg.css({
|
||||
"color": "red",
|
||||
"position": "relative",
|
||||
"background-color": "#f8d7da",
|
||||
"margin-bottom": "10px",
|
||||
"border": "1px solid red"
|
||||
});
|
||||
}
|
||||
|
||||
$msg.text(message).show();
|
||||
setTimeout(function () {
|
||||
$msg.animate({ opacity: 0 }, 500, function () {
|
||||
$msg.hide();
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Popup-Öffnen für die Files Gallery
|
||||
$(".openFileGallery").on("click", function () {
|
||||
var target = $(this).data("target");
|
||||
// Hole den Funktionsnamen aus data-ajax-function, Standardwert falls nicht definiert:
|
||||
var ajaxFunction = $(this).data("ajax-function");
|
||||
var nocache = new Date().getTime(); // Erzeugt einen Zeitstempel
|
||||
window.open(buildFileGalleryUrl(target, "single", ajaxFunction, nocache),
|
||||
"FileGallery", "width=1000,height=600");
|
||||
});
|
||||
|
||||
$(".openFileGalleryMulti").on("click", function () {
|
||||
var target = $(this).data("target");
|
||||
var ajaxFunction = $(this).data("ajax-function");
|
||||
var nocache = new Date().getTime(); // Zeitstempel zur Cache-Vermeidung
|
||||
window.open(buildFileGalleryUrl(target, "multi", ajaxFunction, nocache),
|
||||
"FileGallery", "width=1000,height=600");
|
||||
});
|
||||
|
||||
// Funktion, die vom Files Gallery Popup im Parent-Fenster aufgerufen wird
|
||||
window.setFilePath = function (path) {
|
||||
if (window.fileGalleryTarget) {
|
||||
$("#" + window.fileGalleryTarget).val(path);
|
||||
}
|
||||
};
|
||||
|
||||
// Alternative setFilePath-Funktion im globalen Kontext
|
||||
function setFilePath(path) {
|
||||
var input = document.getElementById(input_id);
|
||||
if (input) {
|
||||
input.value = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Event-Handler für den Clear-Button
|
||||
$(document).on("click", ".clearImage", function () {
|
||||
// Hole den data-target Wert – z.B. "image_1"
|
||||
var targetId = $(this).data("target");
|
||||
|
||||
// Leere das versteckte Input-Feld
|
||||
$("#" + targetId).val("");
|
||||
|
||||
// Suche das Vorschaubild innerhalb des Containers (.single_image_group)
|
||||
$(this).closest('.single_image_group').find('.preview')
|
||||
.attr("src", "") // Bildquelle entfernen
|
||||
.css("background-color", "#f3f3f3"); // Standardhintergrund setzen
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
$('.toggle').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
// Lese das data-toggle-target Attribut des geklickten Elements aus
|
||||
var target = $(this).data('toggle-target');
|
||||
if (target) {
|
||||
// Suche nach allen Elementen mit dem gleichen data-toggle-target,
|
||||
// schließe aber das geklickte Element (und ggf. weitere Toggle-Elemente) aus.
|
||||
var $targetElements = $('[data-toggle-target="' + target + '"]').not('.toggle');
|
||||
if ($targetElements.length) {
|
||||
$targetElements.slideToggle();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
431
module/knowledgecenter_update/Script/migrate_base64_to_files.php
Normal file
431
module/knowledgecenter_update/Script/migrate_base64_to_files.php
Normal file
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
/**
|
||||
* Migrationsskript: Wandelt inline Base64-Bilder in
|
||||
* knowledgecenter_post_versions.content in echte Dateien um.
|
||||
*
|
||||
* CLI-only. Idempotent. Per-Version-Transaktion mit Backup-Tabelle.
|
||||
*
|
||||
* Aufruf:
|
||||
* php migrate_base64_to_files.php --dry-run --post-id=417 --verbose
|
||||
* php migrate_base64_to_files.php --apply --post-id=417
|
||||
* php migrate_base64_to_files.php --apply
|
||||
*
|
||||
* Optionen:
|
||||
* --dry-run Standard. Nur analysieren, nichts schreiben.
|
||||
* --apply Wirklich migrieren (Dateien + DB).
|
||||
* --post-id=N Nur Versionen dieses Posts.
|
||||
* --version-id=N Genau diese Version.
|
||||
* --limit=N Maximal N Versionen verarbeiten.
|
||||
* --verbose Mehr Details ins Log.
|
||||
* --help Diese Hilfe.
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "Dieses Skript ist CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$currDir = __DIR__;
|
||||
$rootDir = dirname(dirname(dirname($currDir)));
|
||||
$mysydeDir = $rootDir . '/mysyde';
|
||||
|
||||
require_once($rootDir . '/vendor/autoload.php');
|
||||
|
||||
$envDir = $rootDir . '/config';
|
||||
$envFilePath = $envDir . '/.env';
|
||||
if (is_dir($envDir) && file_exists($envFilePath) && is_file($envFilePath) && is_readable($envFilePath)) {
|
||||
$dotenv = new \Dotenv\Dotenv($envDir);
|
||||
$dotenv->load();
|
||||
}
|
||||
|
||||
require_once($mysydeDir . '/common/common_functions.inc.php');
|
||||
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
|
||||
require_once($connEnv);
|
||||
db_connect();
|
||||
|
||||
// ------- Args -------
|
||||
$opts = [
|
||||
'dry-run' => true,
|
||||
'apply' => false,
|
||||
'post-id' => null,
|
||||
'version-id' => null,
|
||||
'limit' => null,
|
||||
'verbose' => false,
|
||||
];
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '--help' || $arg === '-h') {
|
||||
echo "Usage: php migrate_base64_to_files.php [--dry-run|--apply] [--post-id=N] [--version-id=N] [--limit=N] [--verbose]\n";
|
||||
exit(0);
|
||||
}
|
||||
if ($arg === '--apply') { $opts['apply'] = true; $opts['dry-run'] = false; continue; }
|
||||
if ($arg === '--dry-run') { $opts['dry-run'] = true; $opts['apply'] = false; continue; }
|
||||
if ($arg === '--verbose') { $opts['verbose'] = true; continue; }
|
||||
if (preg_match('/^--post-id=(\d+)$/', $arg, $m)) { $opts['post-id'] = (int) $m[1]; continue; }
|
||||
if (preg_match('/^--version-id=(\d+)$/', $arg, $m)) { $opts['version-id'] = (int) $m[1]; continue; }
|
||||
if (preg_match('/^--limit=(\d+)$/', $arg, $m)) { $opts['limit'] = (int) $m[1]; continue; }
|
||||
fwrite(STDERR, "Unbekannte Option: $arg\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$dryRun = !$opts['apply'];
|
||||
$verbose = $opts['verbose'];
|
||||
$runId = bin2hex(random_bytes(8));
|
||||
|
||||
// ------- Logging -------
|
||||
$logDir = $rootDir . '/userdata/knowledgecenter_update/migrations';
|
||||
if (!is_dir($logDir) && !@mkdir($logDir, 0775, true)) {
|
||||
fwrite(STDERR, "Log-Verzeichnis nicht anlegbar: $logDir\n");
|
||||
exit(3);
|
||||
}
|
||||
$logFile = $logDir . '/base64_to_files_' . gmdate('Ymd_His') . '_' . $runId . '.log';
|
||||
$logFp = fopen($logFile, 'w');
|
||||
if (!$logFp) {
|
||||
fwrite(STDERR, "Log-Datei nicht öffenbar: $logFile\n");
|
||||
exit(3);
|
||||
}
|
||||
|
||||
function logmsg($msg, $level = 'INFO') {
|
||||
global $logFp, $verbose;
|
||||
$line = '[' . gmdate('Y-m-d H:i:s') . " UTC] [$level] $msg\n";
|
||||
fwrite($logFp, $line);
|
||||
if ($verbose || in_array($level, ['ERROR', 'FAIL', 'OK', 'WARN', 'HEAD'], true)) {
|
||||
fwrite(STDOUT, $line);
|
||||
}
|
||||
}
|
||||
|
||||
logmsg("Migration-Run gestartet. run_id=$runId mode=" . ($dryRun ? 'DRY-RUN' : 'APPLY')
|
||||
. " post-id=" . ($opts['post-id'] ?? 'ALL')
|
||||
. " version-id=" . ($opts['version-id'] ?? 'ALL')
|
||||
. " limit=" . ($opts['limit'] ?? 'unbeschränkt'), 'HEAD');
|
||||
|
||||
// ------- Backup-Tabelle anlegen -------
|
||||
$createBackup = "CREATE TABLE IF NOT EXISTS knowledgecenter_post_versions_migration_backup (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version_id INT NOT NULL,
|
||||
post_id INT NOT NULL,
|
||||
language_id INT NOT NULL,
|
||||
version_number INT NOT NULL,
|
||||
original_content LONGTEXT NOT NULL,
|
||||
original_length INT NOT NULL,
|
||||
images_extracted INT NOT NULL,
|
||||
migrated_at DATETIME NOT NULL,
|
||||
script_run_id VARCHAR(32) NOT NULL,
|
||||
UNIQUE KEY uniq_version_run (version_id, script_run_id),
|
||||
KEY idx_run (script_run_id),
|
||||
KEY idx_post (post_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $createBackup)) {
|
||||
logmsg('Backup-Tabelle konnte nicht angelegt werden: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR');
|
||||
exit(4);
|
||||
}
|
||||
|
||||
// ------- Kandidaten finden -------
|
||||
$where = ["content LIKE '%data:image/%;base64,%'"];
|
||||
if ($opts['post-id'] !== null) $where[] = "post_id = " . (int) $opts['post-id'];
|
||||
if ($opts['version-id'] !== null) $where[] = "id = " . (int) $opts['version-id'];
|
||||
$sql = "SELECT id, post_id, language_id, version_number, content
|
||||
FROM knowledgecenter_post_versions
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY post_id, language_id, version_number";
|
||||
if ($opts['limit'] !== null) $sql .= " LIMIT " . (int) $opts['limit'];
|
||||
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
if (!$res) {
|
||||
logmsg('Query fehlgeschlagen: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR');
|
||||
exit(5);
|
||||
}
|
||||
$candidateCount = mysqli_num_rows($res);
|
||||
logmsg("Gefundene Kandidaten: $candidateCount", 'HEAD');
|
||||
|
||||
// ------- Helper -------
|
||||
function mime_to_ext($mime) {
|
||||
static $map = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
return isset($map[$mime]) ? $map[$mime] : null;
|
||||
}
|
||||
|
||||
function ensure_post_inline_dir($rootDir, $postId, $dryRun) {
|
||||
$hash = substr(sha1('kc_post_' . (int) $postId), 0, 24);
|
||||
$relBase = 'knowledgecenter_update/posts/' . $hash;
|
||||
$absBase = $rootDir . '/userdata/' . $relBase;
|
||||
$absInline = $absBase . '/inline';
|
||||
if ($dryRun) {
|
||||
return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => false];
|
||||
}
|
||||
if (!is_dir($absBase) && !@mkdir($absBase, 0775, true)) {
|
||||
throw new RuntimeException("Post-Ordner nicht anlegbar: $absBase");
|
||||
}
|
||||
$sharedLink = $absBase . '/shared';
|
||||
if (!is_link($sharedLink) && !file_exists($sharedLink)) {
|
||||
@symlink('../../shared', $sharedLink);
|
||||
}
|
||||
if (!is_dir($absInline) && !@mkdir($absInline, 0775, true)) {
|
||||
throw new RuntimeException("Inline-Ordner nicht anlegbar: $absInline");
|
||||
}
|
||||
return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => true];
|
||||
}
|
||||
|
||||
function process_version($row, $rootDir, $dryRun, $runId, $verbose) {
|
||||
$versionId = (int) $row['id'];
|
||||
$postId = (int) $row['post_id'];
|
||||
$langId = (int) $row['language_id'];
|
||||
$verNum = (int) $row['version_number'];
|
||||
$content = (string) $row['content'];
|
||||
$origLen = strlen($content);
|
||||
|
||||
$tag = "version_id=$versionId post_id=$postId lang=$langId v=$verNum";
|
||||
|
||||
// DOM laden
|
||||
$wrapped = '<?xml encoding="UTF-8"?><div id="kc_root">' . $content . '</div>';
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
$loaded = $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
if (!$loaded) {
|
||||
logmsg("[FAIL] $tag DOM load fehlgeschlagen", 'FAIL');
|
||||
return ['ok' => false, 'rewrote' => 0];
|
||||
}
|
||||
|
||||
$root = $dom->getElementById('kc_root');
|
||||
if (!$root) {
|
||||
logmsg("[FAIL] $tag wrapper nicht gefunden", 'FAIL');
|
||||
return ['ok' => false, 'rewrote' => 0];
|
||||
}
|
||||
|
||||
$imgs = $dom->getElementsByTagName('img');
|
||||
$imgList = [];
|
||||
foreach ($imgs as $img) { $imgList[] = $img; }
|
||||
|
||||
$rewrote = 0;
|
||||
$skippedNoBase64 = 0;
|
||||
$skippedBadDecode = 0;
|
||||
$skippedBadMime = 0;
|
||||
$skippedTooBig = 0;
|
||||
$skippedSrcset = 0;
|
||||
$totalBytesIn = 0;
|
||||
|
||||
foreach ($imgList as $img) {
|
||||
if ($img->hasAttribute('srcset')) {
|
||||
$skippedSrcset++;
|
||||
logmsg("[WARN] $tag <img srcset=...> belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$src = $img->getAttribute('src');
|
||||
if ($src === '' || strncmp($src, 'data:', 5) !== 0) continue;
|
||||
|
||||
if (!preg_match('#^data:(image/[a-z0-9.+-]+)?(;charset=[^;,]+)?(;base64)?,(.*)$#is', $src, $m)) {
|
||||
$skippedNoBase64++;
|
||||
logmsg("[WARN] $tag data-URI nicht parsbar, belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$declaredMime = strtolower($m[1] ?? '');
|
||||
$isBase64 = !empty($m[3]);
|
||||
$payload = $m[4];
|
||||
|
||||
if (!$isBase64) {
|
||||
$skippedNoBase64++;
|
||||
logmsg("[WARN] $tag data-URI ohne ;base64, belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
$decoded = base64_decode($payload, true);
|
||||
if ($decoded === false || $decoded === '') {
|
||||
$skippedBadDecode++;
|
||||
logmsg("[WARN] $tag base64 nicht dekodierbar, belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = strlen($decoded);
|
||||
$totalBytesIn += $size;
|
||||
if ($size > 5 * 1024 * 1024) {
|
||||
$skippedTooBig++;
|
||||
logmsg("[WARN] $tag Bild > 5MB ($size bytes), belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$detectedMime = $finfo ? finfo_buffer($finfo, $decoded) : false;
|
||||
if ($finfo) finfo_close($finfo);
|
||||
$ext = mime_to_ext($detectedMime);
|
||||
if (!$ext) {
|
||||
$skippedBadMime++;
|
||||
logmsg("[WARN] $tag Mime '$detectedMime' nicht erlaubt (declared='$declaredMime'), belassen", 'WARN');
|
||||
continue;
|
||||
}
|
||||
|
||||
$dir = ensure_post_inline_dir($rootDir, $postId, $dryRun);
|
||||
$sha = hash('sha256', $decoded);
|
||||
$filename = $sha . '.' . $ext;
|
||||
$finalPath = $dir['absInline'] . '/' . $filename;
|
||||
$publicUrl = '/userdata/' . $dir['relBase'] . '/inline/' . $filename;
|
||||
|
||||
if ($dryRun) {
|
||||
if ($verbose) logmsg("[DRY ] $tag would write $finalPath ($size bytes) → $publicUrl", 'INFO');
|
||||
} else {
|
||||
$writeNeeded = true;
|
||||
if (file_exists($finalPath)) {
|
||||
$existingSize = filesize($finalPath);
|
||||
if ($existingSize === $size) {
|
||||
$writeNeeded = false;
|
||||
if ($verbose) logmsg("[INFO] $tag dedup hit $finalPath", 'INFO');
|
||||
} else {
|
||||
logmsg("[WARN] $tag SHA-Kollision?! $finalPath bestehend=$existingSize bytes, neu=$size bytes – überspringe", 'WARN');
|
||||
$writeNeeded = false;
|
||||
}
|
||||
}
|
||||
if ($writeNeeded) {
|
||||
$tmp = $dir['absInline'] . '/.tmp_' . bin2hex(random_bytes(6));
|
||||
if (file_put_contents($tmp, $decoded) === false) {
|
||||
logmsg("[FAIL] $tag konnte tmp-Datei nicht schreiben", 'FAIL');
|
||||
return ['ok' => false, 'rewrote' => $rewrote];
|
||||
}
|
||||
@chmod($tmp, 0664);
|
||||
if (!rename($tmp, $finalPath)) {
|
||||
@unlink($tmp);
|
||||
logmsg("[FAIL] $tag rename failed", 'FAIL');
|
||||
return ['ok' => false, 'rewrote' => $rewrote];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$img->setAttribute('src', $publicUrl);
|
||||
$rewrote++;
|
||||
}
|
||||
|
||||
// Serialisieren
|
||||
$newContent = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$newContent .= $dom->saveHTML($child);
|
||||
}
|
||||
|
||||
$newLen = strlen($newContent);
|
||||
$report = sprintf(
|
||||
"%s images_rewritten=%d skipped(no_b64=%d bad_decode=%d bad_mime=%d too_big=%d srcset=%d) from_kb=%d to_kb=%d",
|
||||
$tag, $rewrote, $skippedNoBase64, $skippedBadDecode, $skippedBadMime, $skippedTooBig, $skippedSrcset,
|
||||
(int) round($origLen / 1024), (int) round($newLen / 1024)
|
||||
);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'rewrote' => $rewrote,
|
||||
'newContent'=> $newContent,
|
||||
'origLen' => $origLen,
|
||||
'newLen' => $newLen,
|
||||
'report' => $report,
|
||||
];
|
||||
}
|
||||
|
||||
// ------- Verarbeitung -------
|
||||
$totals = ['ok' => 0, 'rewritten_images' => 0, 'fail' => 0, 'no_change' => 0, 'bytes_saved' => 0];
|
||||
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$versionId = (int) $row['id'];
|
||||
$postId = (int) $row['post_id'];
|
||||
|
||||
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'START TRANSACTION');
|
||||
try {
|
||||
$result = process_version($row, $rootDir, $dryRun, $runId, $verbose);
|
||||
} catch (Throwable $e) {
|
||||
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] version_id=$versionId Exception: " . $e->getMessage(), 'FAIL');
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
if (!$result['ok']) {
|
||||
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($result['rewrote'] === 0) {
|
||||
if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[SKIP] " . $result['report'] . " (kein <img> umgeschrieben)", 'WARN');
|
||||
$totals['no_change']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
logmsg("[DRY ] " . $result['report'], 'OK');
|
||||
$totals['ok']++;
|
||||
$totals['rewritten_images'] += $result['rewrote'];
|
||||
$totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backup + UPDATE
|
||||
$stmt = mysqli_prepare(
|
||||
$GLOBALS['mysql_con'],
|
||||
"INSERT INTO knowledgecenter_post_versions_migration_backup
|
||||
(version_id, post_id, language_id, version_number, original_content, original_length, images_extracted, migrated_at, script_run_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)"
|
||||
);
|
||||
if (!$stmt) {
|
||||
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] version_id=$versionId backup-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
$verId = (int) $row['id'];
|
||||
$pId = (int) $row['post_id'];
|
||||
$lId = (int) $row['language_id'];
|
||||
$vNum = (int) $row['version_number'];
|
||||
$origCt = (string) $row['content'];
|
||||
$origLen = $result['origLen'];
|
||||
$rewrote = $result['rewrote'];
|
||||
mysqli_stmt_bind_param($stmt, 'iiiisiis', $verId, $pId, $lId, $vNum, $origCt, $origLen, $rewrote, $runId);
|
||||
if (!mysqli_stmt_execute($stmt)) {
|
||||
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] version_id=$versionId backup-insert: " . mysqli_stmt_error($stmt), 'FAIL');
|
||||
mysqli_stmt_close($stmt);
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
mysqli_stmt_close($stmt);
|
||||
|
||||
$stmt2 = mysqli_prepare(
|
||||
$GLOBALS['mysql_con'],
|
||||
"UPDATE knowledgecenter_post_versions SET content = ? WHERE id = ?"
|
||||
);
|
||||
if (!$stmt2) {
|
||||
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] version_id=$versionId update-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
$newCt = $result['newContent'];
|
||||
mysqli_stmt_bind_param($stmt2, 'si', $newCt, $verId);
|
||||
if (!mysqli_stmt_execute($stmt2)) {
|
||||
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] version_id=$versionId update-exec: " . mysqli_stmt_error($stmt2), 'FAIL');
|
||||
mysqli_stmt_close($stmt2);
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
mysqli_stmt_close($stmt2);
|
||||
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], 'COMMIT')) {
|
||||
logmsg("[FAIL] version_id=$versionId COMMIT-Fehler: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL');
|
||||
$totals['fail']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
logmsg("[OK ] " . $result['report'], 'OK');
|
||||
$totals['ok']++;
|
||||
$totals['rewritten_images'] += $result['rewrote'];
|
||||
$totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']);
|
||||
}
|
||||
|
||||
logmsg(sprintf(
|
||||
"Fertig. ok=%d fail=%d no_change=%d rewritten_images=%d bytes_saved=%d (%.2f MB) mode=%s",
|
||||
$totals['ok'], $totals['fail'], $totals['no_change'], $totals['rewritten_images'],
|
||||
$totals['bytes_saved'], $totals['bytes_saved'] / 1024 / 1024,
|
||||
$dryRun ? 'DRY-RUN' : 'APPLY'
|
||||
), 'HEAD');
|
||||
logmsg("Log-Datei: $logFile", 'HEAD');
|
||||
|
||||
fclose($logFp);
|
||||
exit($totals['fail'] > 0 ? 1 : 0);
|
||||
473
module/knowledgecenter_update/Script/migrate_old_wiki.php
Normal file
473
module/knowledgecenter_update/Script/migrate_old_wiki.php
Normal file
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: Alte Wiki-Artikel (main_collection) → Neue Wiki-Struktur (knowledgecenter_posts)
|
||||
*
|
||||
* CLI-only. Idempotent (über legacy_collection_id). Pro Artikel transaktional.
|
||||
*
|
||||
* Aufruf:
|
||||
* php migrate_old_wiki.php --dry-run --collection-id=1491
|
||||
* php migrate_old_wiki.php --apply --collection-id=1491 --api-key=KEY
|
||||
* php migrate_old_wiki.php --apply --limit=5 --api-key=KEY
|
||||
* php migrate_old_wiki.php --apply (alle Artikel)
|
||||
*
|
||||
* Optionen:
|
||||
* --dry-run Standard. Nur analysieren, nichts schreiben.
|
||||
* --apply Wirklich migrieren (DB + Dateien).
|
||||
* --collection-id=N Nur diesen Artikel migrieren.
|
||||
* --limit=N Maximal N Artikel migrieren (für Tests).
|
||||
* --api-key=KEY Gemini API Key (oder via GEMINI_API_KEY env).
|
||||
* --skip-summaries Keine KI-Zusammenfassung generieren.
|
||||
* --verbose Mehr Details ausgeben.
|
||||
* --help Diese Hilfe.
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "Dieses Skript ist CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$currDir = __DIR__;
|
||||
$rootDir = dirname(dirname(dirname($currDir)));
|
||||
$mysydeDir = $rootDir . '/mysyde';
|
||||
|
||||
require_once($rootDir . '/vendor/autoload.php');
|
||||
|
||||
$envDir = $rootDir . '/config';
|
||||
$envFilePath = $envDir . '/.env';
|
||||
if (is_dir($envDir) && file_exists($envFilePath) && is_readable($envFilePath)) {
|
||||
$dotenv = new \Dotenv\Dotenv($envDir);
|
||||
$dotenv->load();
|
||||
}
|
||||
|
||||
require_once($mysydeDir . '/common/common_functions.inc.php');
|
||||
$connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php';
|
||||
require_once($connEnv);
|
||||
db_connect();
|
||||
|
||||
// ------- Args -------
|
||||
$opts = [
|
||||
'dry-run' => true,
|
||||
'apply' => false,
|
||||
'collection-id' => null,
|
||||
'limit' => null,
|
||||
'api-key' => getenv('GEMINI_API_KEY') ?: null,
|
||||
'skip-summaries' => false,
|
||||
'summaries-only' => false,
|
||||
'verbose' => false,
|
||||
];
|
||||
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '--help' || $arg === '-h') {
|
||||
echo "Usage: php migrate_old_wiki.php [--dry-run|--apply] [--collection-id=N] [--limit=N] [--api-key=KEY] [--skip-summaries] [--verbose]\n";
|
||||
exit(0);
|
||||
}
|
||||
if ($arg === '--apply') { $opts['apply'] = true; $opts['dry-run'] = false; continue; }
|
||||
if ($arg === '--dry-run') { $opts['dry-run'] = true; $opts['apply'] = false; continue; }
|
||||
if ($arg === '--skip-summaries') { $opts['skip-summaries'] = true; continue; }
|
||||
if ($arg === '--summaries-only') { $opts['summaries-only'] = true; continue; }
|
||||
if ($arg === '--verbose') { $opts['verbose'] = true; continue; }
|
||||
if (preg_match('/^--collection-id=(\d+)$/', $arg, $m)) { $opts['collection-id'] = (int)$m[1]; continue; }
|
||||
if (preg_match('/^--limit=(\d+)$/', $arg, $m)) { $opts['limit'] = (int)$m[1]; continue; }
|
||||
if (preg_match('/^--api-key=(.+)$/', $arg, $m)) { $opts['api-key'] = $m[1]; continue; }
|
||||
fwrite(STDERR, "Unbekannte Option: $arg\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$dryRun = !$opts['apply'];
|
||||
$verbose = $opts['verbose'];
|
||||
$apiKey = $opts['api-key'];
|
||||
$skipSummary = $opts['skip-summaries'];
|
||||
$userdataDir = $rootDir . '/userdata';
|
||||
|
||||
// ------- Log-Datei -------
|
||||
$logDir = $userdataDir . '/knowledgecenter_update/migrations';
|
||||
if (!is_dir($logDir)) @mkdir($logDir, 0775, true);
|
||||
$logFile = $logDir . '/wiki_migration_' . gmdate('Ymd_His') . '.log';
|
||||
$logFp = fopen($logFile, 'w');
|
||||
if (!$logFp) { fwrite(STDERR, "Log-Datei nicht anlegbar: $logFile\n"); exit(3); }
|
||||
fwrite(STDOUT, "Log: $logFile\n");
|
||||
|
||||
// ------- Logging -------
|
||||
function logmsg(string $msg, string $level = 'INFO'): void {
|
||||
global $verbose, $logFp;
|
||||
$line = '[' . gmdate('Y-m-d H:i:s') . " UTC] [$level] $msg\n";
|
||||
fwrite($logFp, $line);
|
||||
if ($verbose || in_array($level, ['ERROR', 'FAIL', 'OK', 'WARN', 'HEAD', 'SKIP', 'PROMPT'], true)) {
|
||||
fwrite(STDOUT, $line);
|
||||
}
|
||||
}
|
||||
|
||||
// ------- Phase 0: Schema-Vorbereitung -------
|
||||
logmsg("Prüfe Schema ...", 'HEAD');
|
||||
|
||||
mysqli_query($GLOBALS['mysql_con'],
|
||||
"ALTER TABLE knowledgecenter_posts ADD COLUMN IF NOT EXISTS legacy_collection_id INT NULL");
|
||||
mysqli_query($GLOBALS['mysql_con'],
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_legacy_collection ON knowledgecenter_posts(legacy_collection_id)");
|
||||
|
||||
// ------- Hilfsfunktionen -------
|
||||
|
||||
/**
|
||||
* Returns ['teaser' => string, 'content' => string]
|
||||
* setup_content_id=55 (kc_teaser, data-field) → teaser
|
||||
* setup_content_id=58 (kc_article, textcontent_header) → content
|
||||
*/
|
||||
function assemble_content(int $cid): array {
|
||||
$sql = "SELECT mcl.main_collection_setup_content_id, mcl.main_sitepart_id,
|
||||
mcl.main_sitepart_header_id, mcl.data
|
||||
FROM main_collection_link mcl
|
||||
WHERE mcl.main_collection_id = $cid
|
||||
ORDER BY mcl.id";
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
$teaser = '';
|
||||
$content = '';
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$scid = (int)$row['main_collection_setup_content_id'];
|
||||
if ($scid === 55) {
|
||||
// Teaser: plain text im data-Feld, keine HTML-Tags
|
||||
$text = trim($row['data'] ?? '');
|
||||
if ($text !== '') {
|
||||
$teaser .= ($teaser !== '' ? ' ' : '') . $text;
|
||||
}
|
||||
} elseif ($row['main_sitepart_id'] == 1 && $row['main_sitepart_header_id'] > 0) {
|
||||
// Artikeltext: HTML aus textcontent_header (setup_content_id=58 oder ähnlich)
|
||||
$hid = (int)$row['main_sitepart_header_id'];
|
||||
$hres = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT content FROM textcontent_header WHERE id = $hid LIMIT 1");
|
||||
if ($hrow = mysqli_fetch_assoc($hres)) {
|
||||
$content .= $hrow['content'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['teaser' => $teaser, 'content' => $content];
|
||||
}
|
||||
|
||||
function get_article_files(int $cid): array {
|
||||
// sitepart_id 5, 6, 10 sind filegallery-Typen; sitepart_id=1 ist textcontent_header (nicht joinen!)
|
||||
$sql = "SELECT DISTINCT fl.filename, fl.extension, fl.description
|
||||
FROM main_collection_link mcl
|
||||
JOIN filegallery_line fl ON fl.header_id = mcl.main_sitepart_header_id
|
||||
WHERE mcl.main_collection_id = $cid
|
||||
AND mcl.main_sitepart_id IN (5, 6, 10)
|
||||
AND mcl.main_sitepart_header_id > 0
|
||||
AND fl.filename != ''
|
||||
ORDER BY fl.sorting";
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
$files = [];
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$files[] = $row;
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
function extract_pdf_text(string $filePath): string {
|
||||
if (!file_exists($filePath) || filesize($filePath) === 0) return '';
|
||||
|
||||
// pdftotext ist robuster bei gesicherten PDFs (nur Kopier-Schutz, kein Passwort)
|
||||
$pdftotext = trim(shell_exec('which pdftotext 2>/dev/null') ?? '');
|
||||
if ($pdftotext !== '') {
|
||||
$escaped = escapeshellarg($filePath);
|
||||
$out = shell_exec("$pdftotext -nopgbrk $escaped - 2>/dev/null");
|
||||
if ($out !== null && strlen(trim($out)) > 10) {
|
||||
return preg_replace('/\s+/', ' ', trim($out));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: pdfparser
|
||||
try {
|
||||
$parser = new \Smalot\PdfParser\Parser();
|
||||
$pdf = $parser->parseFile($filePath);
|
||||
$text = trim($pdf->getText());
|
||||
return preg_replace('/\s+/', ' ', $text);
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function generate_summary(string $htmlContent, array $files, string $userdataDir, string $apiKey, string $title, string $teaser = ''): ?string {
|
||||
$parts = [];
|
||||
|
||||
// Teaser bevorzugen: bereits plain text, sehr kurz → spart Input-Tokens
|
||||
if (strlen($teaser) > 30) {
|
||||
$parts[] = $teaser;
|
||||
}
|
||||
|
||||
// Fließtext: HTML entfernen, auf 1500 Zeichen cappen
|
||||
$plainText = html_entity_decode(strip_tags($htmlContent), ENT_HTML5 | ENT_QUOTES, 'UTF-8');
|
||||
$plainText = trim(preg_replace('/\s+/', ' ', $plainText));
|
||||
if (strlen($plainText) > 30) {
|
||||
$parts[] = mb_substr($plainText, 0, 1500);
|
||||
}
|
||||
|
||||
// PDFs nur wenn sonst kein Inhalt vorhanden, auf 800 Zeichen begrenzen
|
||||
if (empty($parts)) {
|
||||
foreach ($files as $file) {
|
||||
if (strtolower($file['extension']) !== 'pdf') continue;
|
||||
$pdfText = extract_pdf_text($userdataDir . '/' . $file['filename']);
|
||||
if (strlen($pdfText) > 50) {
|
||||
$label = $file['description'] ?: basename($file['filename']);
|
||||
$parts[] = 'Anhang "' . $label . '": ' . mb_substr($pdfText, 0, 800);
|
||||
break; // ein PDF reicht
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = "Fasse in 2-3 deutschen Sätzen zusammen. Nur Zusammenfassung.\nTitel: $title\n\n"
|
||||
. implode("\n\n", $parts);
|
||||
|
||||
logmsg(" PROMPT (" . strlen($prompt) . " Zeichen)", 'PROMPT');
|
||||
|
||||
$payload = json_encode([
|
||||
'contents' => [['parts' => [['text' => $prompt]]]],
|
||||
'generationConfig' => [
|
||||
'maxOutputTokens' => 150,
|
||||
'temperature' => 0.2,
|
||||
'thinkingConfig' => ['thinkingBudget' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
$url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=' . urlencode($apiKey);
|
||||
$resp = '';
|
||||
$httpCode = 0;
|
||||
$attempts = [[0, null], [5, 503], [15, 503], [30, 429], [60, 429]];
|
||||
foreach ($attempts as [$wait, $label]) {
|
||||
if ($wait > 0) {
|
||||
logmsg(" Gemini $httpCode – warte {$wait}s (retry) ...", 'WARN');
|
||||
sleep($wait);
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($httpCode !== 503 && $httpCode !== 429) break;
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
logmsg("Gemini API Fehler HTTP $httpCode: " . substr($resp, 0, 200), 'WARN');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
$summary = trim($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
if (strlen($summary) < 30) {
|
||||
logmsg(" Gemini Roh-Antwort: " . substr($resp, 0, 500), 'WARN');
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
// ------- Permissions aus main_collection auf Post übertragen -------
|
||||
function copy_permissions_to_post(int $postId, int $collectionId): void {
|
||||
$maps = [
|
||||
['src' => 'main_collection_mandant_link', 'src_col' => 'main_mandant_id', 'dst' => 'knowledgecenter_post_mandant', 'dst_col' => 'mandant_id'],
|
||||
['src' => 'main_collection_department_link', 'src_col' => 'main_department_id', 'dst' => 'knowledgecenter_post_department', 'dst_col' => 'department_id'],
|
||||
['src' => 'main_collection_role_link', 'src_col' => 'main_role_id', 'dst' => 'knowledgecenter_post_role', 'dst_col' => 'role_id'],
|
||||
['src' => 'main_collection_einricht_link', 'src_col' => 'main_einricht_id', 'dst' => 'knowledgecenter_post_einricht', 'dst_col' => 'einricht_id'],
|
||||
['src' => 'main_collection_fachbereich_link', 'src_col' => 'main_fachbereich_id', 'dst' => 'knowledgecenter_post_fachbereich', 'dst_col' => 'fachbereich_id'],
|
||||
];
|
||||
|
||||
foreach ($maps as $m) {
|
||||
$sql = "INSERT IGNORE INTO {$m['dst']} (post_id, {$m['dst_col']})
|
||||
SELECT $postId, {$m['src_col']} FROM {$m['src']} WHERE main_collection_id = $collectionId";
|
||||
mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
}
|
||||
}
|
||||
|
||||
// ------- Artikel laden -------
|
||||
$whereClause = "mc.main_collection_setup_id = 3 AND mc.is_archived = 0 AND mc.active = 1 AND mc.description != ''";
|
||||
if ($opts['collection-id'] !== null) {
|
||||
$whereClause .= " AND mc.id = " . (int)$opts['collection-id'];
|
||||
}
|
||||
|
||||
// ------- Summaries-only Modus: fehlende Summaries für bereits migrierte Posts nachfüllen -------
|
||||
if ($opts['summaries-only']) {
|
||||
if (!$apiKey) { fwrite(STDERR, "--summaries-only benötigt --api-key\n"); exit(2); }
|
||||
|
||||
$sumSql = "SELECT p.id as post_id, p.legacy_collection_id, pv.id as version_id, pv.title,
|
||||
pv.teaser, pv.content
|
||||
FROM knowledgecenter_posts p
|
||||
JOIN knowledgecenter_post_versions pv ON pv.post_id = p.id AND pv.version_number = 1
|
||||
WHERE (pv.summary IS NULL OR pv.summary = '')
|
||||
AND p.legacy_collection_id IS NOT NULL";
|
||||
if ($opts['collection-id'] !== null) {
|
||||
$sumSql .= " AND p.legacy_collection_id = " . (int)$opts['collection-id'];
|
||||
}
|
||||
if ($opts['limit'] !== null) {
|
||||
$sumSql .= " LIMIT " . (int)$opts['limit'];
|
||||
}
|
||||
$sumResult = mysqli_query($GLOBALS['mysql_con'], $sumSql);
|
||||
$sumTotal = mysqli_num_rows($sumResult);
|
||||
logmsg("Summaries-only: $sumTotal Posts ohne Summary", 'HEAD');
|
||||
$sumStats = ['ok' => 0, 'fail' => 0];
|
||||
|
||||
while ($sumRow = mysqli_fetch_assoc($sumResult)) {
|
||||
$postId = (int)$sumRow['post_id'];
|
||||
$collId = (int)$sumRow['legacy_collection_id'];
|
||||
$versionId = (int)$sumRow['version_id'];
|
||||
$title = $sumRow['title'];
|
||||
|
||||
$files = get_article_files($collId);
|
||||
$teaser = $sumRow['teaser'] ?? '';
|
||||
$summary = generate_summary($sumRow['content'], $files, $userdataDir, $apiKey, $title, $teaser) ?? '';
|
||||
|
||||
if ($summary === '') {
|
||||
logmsg(" [FAIL] Post $postId \"$title\": leere Summary", 'WARN');
|
||||
$sumStats['fail']++;
|
||||
} else {
|
||||
$sumEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $summary);
|
||||
mysqli_query($GLOBALS['mysql_con'],
|
||||
"UPDATE knowledgecenter_post_versions SET summary = '$sumEsc' WHERE id = $versionId");
|
||||
logmsg("[OK ] Post $postId \"$title\": " . mb_substr($summary, 0, 80), 'OK');
|
||||
$sumStats['ok']++;
|
||||
}
|
||||
sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
|
||||
}
|
||||
|
||||
logmsg(sprintf("Summaries fertig. ok=%d fail=%d", $sumStats['ok'], $sumStats['fail']), 'HEAD');
|
||||
logmsg("Log-Datei: $logFile", 'HEAD');
|
||||
fclose($logFp);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$sql = "SELECT mc.id, mc.description, mc.creation_date, mc.modified_date,
|
||||
mc.validity_from, mc.validity_to
|
||||
FROM main_collection mc
|
||||
WHERE $whereClause
|
||||
ORDER BY mc.id";
|
||||
if ($opts['limit'] !== null) {
|
||||
$sql .= " LIMIT " . (int)$opts['limit'];
|
||||
}
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
$total = mysqli_num_rows($result);
|
||||
|
||||
logmsg("Modus=" . ($dryRun ? 'DRY-RUN' : 'APPLY') . " Artikel=$total Limit=" .
|
||||
($opts['limit'] ?? 'alle') . " API=" . ($apiKey && !$skipSummary ? 'ja' : 'nein'), 'HEAD');
|
||||
|
||||
$stats = ['ok' => 0, 'skipped' => 0, 'fail' => 0];
|
||||
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$cid = (int)$row['id'];
|
||||
$title = $row['description'];
|
||||
|
||||
// Idempotenz-Check
|
||||
$already = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT id FROM knowledgecenter_posts WHERE legacy_collection_id = $cid LIMIT 1");
|
||||
if ($already && mysqli_num_rows($already) > 0) {
|
||||
logmsg("Artikel $cid \"$title\" – bereits migriert, überspringe.", 'SKIP');
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = assemble_content($cid);
|
||||
$teaserHtml = $parts['teaser'];
|
||||
$htmlContent = $parts['content'];
|
||||
$files = get_article_files($cid);
|
||||
$htmlLen = strlen($htmlContent);
|
||||
|
||||
$catRes = mysqli_query($GLOBALS['mysql_con'],
|
||||
"SELECT knowledgecenter_category_id FROM knowledgecenter_collection_link WHERE main_collection_id = $cid");
|
||||
$catIds = [];
|
||||
while ($cr = mysqli_fetch_assoc($catRes)) {
|
||||
$catIds[] = (int)$cr['knowledgecenter_category_id'];
|
||||
}
|
||||
|
||||
logmsg("Artikel $cid \"$title\" | Teaser=" . strlen($teaserHtml) . "B | HTML=" . $htmlLen . "B | Dateien=" . count($files) .
|
||||
" | Kategorien=[" . implode(',', $catIds) . "]");
|
||||
|
||||
if ($dryRun) {
|
||||
$stats['ok']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Summary generieren (vor der Transaktion, API-Call außerhalb)
|
||||
$summary = '';
|
||||
if (!$skipSummary && $apiKey) {
|
||||
$fullContent = $teaserHtml . $htmlContent;
|
||||
$generated = generate_summary($fullContent, $files, $userdataDir, $apiKey, $title);
|
||||
$summary = $generated ?? '';
|
||||
logmsg(" Summary: " . (strlen($summary) > 0 ? '"' . mb_substr($summary, 0, 100) . '"' : '(leer)'));
|
||||
sleep(4); // 15 RPM Free-Tier: mind. 4 Sek. zwischen Requests
|
||||
}
|
||||
|
||||
// HTML-Entities dekodieren für saubere UTF-8 Speicherung
|
||||
$cleanHtml = html_entity_decode($htmlContent, ENT_HTML5 | ENT_QUOTES, 'UTF-8');
|
||||
|
||||
mysqli_query($GLOBALS['mysql_con'], 'START TRANSACTION');
|
||||
try {
|
||||
$titleEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $title);
|
||||
$summaryEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $summary);
|
||||
$teaserEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $teaserHtml);
|
||||
$contentEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $cleanHtml);
|
||||
$createdAt = date('Y-m-d H:i:s', (int)$row['creation_date']);
|
||||
$modifiedAt = date('Y-m-d H:i:s', (int)$row['modified_date']);
|
||||
$validFrom = $row['validity_from'] ? "'" . $row['validity_from'] . "'" : 'NULL';
|
||||
$validUntil = $row['validity_to'] ? "'" . $row['validity_to'] . "'" : 'NULL';
|
||||
|
||||
// knowledgecenter_posts
|
||||
$sqlPost = "INSERT INTO knowledgecenter_posts (legacy_collection_id, created_at, modified_at)
|
||||
VALUES ($cid, '$createdAt', '$modifiedAt')";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sqlPost)) {
|
||||
throw new RuntimeException("Post-Insert: " . mysqli_error($GLOBALS['mysql_con']));
|
||||
}
|
||||
$postId = (int)mysqli_insert_id($GLOBALS['mysql_con']);
|
||||
|
||||
// knowledgecenter_post_versions
|
||||
$sqlVer = "INSERT INTO knowledgecenter_post_versions
|
||||
(post_id, version_number, language_id, title, teaser, content, summary, state, modified_at, valid_from, valid_until)
|
||||
VALUES
|
||||
($postId, 1, 1, '$titleEsc', '$teaserEsc', '$contentEsc', '$summaryEsc', 1, '$modifiedAt', $validFrom, $validUntil)";
|
||||
if (!mysqli_query($GLOBALS['mysql_con'], $sqlVer)) {
|
||||
throw new RuntimeException("Version-Insert: " . mysqli_error($GLOBALS['mysql_con']));
|
||||
}
|
||||
|
||||
// Kategorie-Links
|
||||
foreach ($catIds as $catId) {
|
||||
mysqli_query($GLOBALS['mysql_con'],
|
||||
"INSERT IGNORE INTO knowledgecenter_category_post (category_id, post_id) VALUES ($catId, $postId)");
|
||||
}
|
||||
|
||||
// Permissions aus main_collection auf Post übertragen
|
||||
copy_permissions_to_post($postId, $cid);
|
||||
|
||||
// Dateiverknüpfungen: Original-Pfad beibehalten, keine Kopie
|
||||
foreach ($files as $file) {
|
||||
$srcPath = $userdataDir . '/' . $file['filename'];
|
||||
if (!file_exists($srcPath)) {
|
||||
logmsg(" Quelldatei nicht gefunden: " . $file['filename'], 'WARN');
|
||||
continue;
|
||||
}
|
||||
$relPath = '/userdata/' . $file['filename'];
|
||||
$descEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $file['description'] ?: basename($file['filename']));
|
||||
$pathEsc = mysqli_real_escape_string($GLOBALS['mysql_con'], $relPath);
|
||||
mysqli_query($GLOBALS['mysql_con'],
|
||||
"INSERT INTO knowledgecenter_post_files (post_id, description, path)
|
||||
VALUES ($postId, '$descEsc', '$pathEsc')");
|
||||
logmsg(" Datei verknüpft: " . $file['filename']);
|
||||
}
|
||||
|
||||
mysqli_query($GLOBALS['mysql_con'], 'COMMIT');
|
||||
logmsg("[OK ] Artikel $cid → Post $postId \"$title\"", 'OK');
|
||||
$stats['ok']++;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK');
|
||||
logmsg("[FAIL] Artikel $cid \"$title\": " . $e->getMessage(), 'FAIL');
|
||||
$stats['fail']++;
|
||||
}
|
||||
}
|
||||
|
||||
logmsg(sprintf("Fertig. ok=%d skipped=%d fail=%d modus=%s",
|
||||
$stats['ok'], $stats['skipped'], $stats['fail'],
|
||||
$dryRun ? 'DRY-RUN' : 'APPLY'), 'HEAD');
|
||||
logmsg("Log-Datei: $logFile", 'HEAD');
|
||||
fclose($logFp);
|
||||
@@ -0,0 +1 @@
|
||||
<iframe class="fullscreen" src="/intranet/de/wiki/?action=Categories&embedded=1" frameborder="0"></iframe>
|
||||
901
module/knowledgecenter_update/Views/categories_cardform.php
Normal file
901
module/knowledgecenter_update/Views/categories_cardform.php
Normal file
@@ -0,0 +1,901 @@
|
||||
<?php
|
||||
// ------------------------------
|
||||
// Cardform mit zentralem Übersetzungsspeicher und Unterkategorien
|
||||
// ------------------------------
|
||||
|
||||
// ini_set('display_errors', 1);
|
||||
// ini_set('display_startup_errors', 1);
|
||||
// error_reporting(E_ALL);
|
||||
|
||||
// Initialisierung der Variablen
|
||||
if (isset($_GET['detail']) && $_GET['detail'] === 'new') {
|
||||
require_permission('r-wiki');
|
||||
$categoryId = 0; // Kennzeichne, dass es sich um einen neuen Eintrag handelt
|
||||
// Leere Standardwerte definieren:
|
||||
$category = [
|
||||
'id' => 0,
|
||||
'modified_at' => '',
|
||||
'modified_by' => ''
|
||||
];
|
||||
// Für alle Sprachen leere Übersetzungen:
|
||||
$translations = [];
|
||||
foreach ($languages as $lang) {
|
||||
$translations[$lang['code']] = ['title' => '', 'image' => ''];
|
||||
}
|
||||
} else {
|
||||
$categoryId = isset($_GET['detail']) ? (int) $_GET['detail'] : 0;
|
||||
$query = "SELECT c.* FROM knowledgecenter_categories_update AS c WHERE c.id = $categoryId LIMIT 1";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$category = mysqli_fetch_assoc($result);
|
||||
$state = isset($category['state']) ? (int) $category['state'] : 0;
|
||||
$translations = [];
|
||||
foreach ($languages as $lang) {
|
||||
$langId = $lang['id'];
|
||||
$transQuery = "SELECT title, image, is_navigation_item
|
||||
FROM knowledgecenter_category_translations
|
||||
WHERE category_id = $categoryId AND language_id = $langId LIMIT 1";
|
||||
$transResult = mysqli_query($GLOBALS['mysql_con'], $transQuery);
|
||||
$translations[$lang['code']] = mysqli_fetch_assoc($transResult) ?: [
|
||||
'title' => '',
|
||||
'image' => '',
|
||||
'is_navigation_item' => 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Mandanten abrufen
|
||||
$mandants = [];
|
||||
$resultMandants = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_mandant");
|
||||
while ($row = mysqli_fetch_assoc($resultMandants)) {
|
||||
$mandants[] = $row;
|
||||
}
|
||||
|
||||
// Departments abrufen
|
||||
$departments = [];
|
||||
$resultDepartments = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_department");
|
||||
while ($row = mysqli_fetch_assoc($resultDepartments)) {
|
||||
$departments[] = $row;
|
||||
}
|
||||
|
||||
// Rollen abrufen
|
||||
$roles = [];
|
||||
$resultRoles = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM main_role");
|
||||
while ($row = mysqli_fetch_assoc($resultRoles)) {
|
||||
$roles[] = $row;
|
||||
}
|
||||
|
||||
// Gespeicherte Mandanten abrufen
|
||||
$savedMandants = [];
|
||||
$querySavedMandants = "SELECT mandant_id FROM knowledgecenter_category_mandant WHERE category_id = $categoryId";
|
||||
$resultSavedMandants = mysqli_query($GLOBALS['mysql_con'], $querySavedMandants);
|
||||
while ($row = mysqli_fetch_assoc($resultSavedMandants)) {
|
||||
$savedMandants[] = $row['mandant_id'];
|
||||
}
|
||||
|
||||
// Array mit Daten für Tagify (Mandanten)
|
||||
$savedMandantsData = [];
|
||||
foreach ($mandants as $mandant) {
|
||||
if (in_array($mandant['id'], $savedMandants)) {
|
||||
$savedMandantsData[] = ['value' => $mandant['id'], 'name' => $mandant['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// Gespeicherte Departments abrufen
|
||||
$savedDepartments = [];
|
||||
$querySavedDepartments = "SELECT department_id FROM knowledgecenter_category_department WHERE category_id = $categoryId";
|
||||
$resultSavedDepartments = mysqli_query($GLOBALS['mysql_con'], $querySavedDepartments);
|
||||
while ($row = mysqli_fetch_assoc($resultSavedDepartments)) {
|
||||
$savedDepartments[] = $row['department_id'];
|
||||
}
|
||||
|
||||
// Array mit Daten für Tagify (Departments)
|
||||
$savedDepartmentsData = [];
|
||||
foreach ($departments as $department) {
|
||||
if (in_array($department['id'], $savedDepartments)) {
|
||||
$savedDepartmentsData[] = ['value' => $department['id'], 'name' => $department['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// Gespeicherte Rollen abrufen
|
||||
$savedRoles = [];
|
||||
$querySavedRoles = "SELECT role_id FROM knowledgecenter_category_role WHERE category_id = $categoryId";
|
||||
$resultSavedRoles = mysqli_query($GLOBALS['mysql_con'], $querySavedRoles);
|
||||
while ($row = mysqli_fetch_assoc($resultSavedRoles)) {
|
||||
$savedRoles[] = $row['role_id'];
|
||||
}
|
||||
|
||||
// Array mit Daten für Tagify (Rollen)
|
||||
$savedRolesData = [];
|
||||
foreach ($roles as $role) {
|
||||
if (in_array($role['id'], $savedRoles)) {
|
||||
$savedRolesData[] = ['value' => $role['id'], 'name' => $role['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// Einrichtungen abrufen
|
||||
$einrichts = [];
|
||||
$resultEinrichts = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM organigramm_einricht ORDER BY description ASC");
|
||||
while ($row = mysqli_fetch_assoc($resultEinrichts)) {
|
||||
$einrichts[] = $row;
|
||||
}
|
||||
|
||||
// Fachbereiche abrufen
|
||||
$fachbereiche = [];
|
||||
$resultFachbereiche = mysqli_query($GLOBALS['mysql_con'], "SELECT id, description FROM organogramm_space ORDER BY description ASC");
|
||||
while ($row = mysqli_fetch_assoc($resultFachbereiche)) {
|
||||
$fachbereiche[] = $row;
|
||||
}
|
||||
|
||||
// Gespeicherte Einrichtungen abrufen
|
||||
$savedEinrichts = [];
|
||||
$querySavedEinrichts = "SELECT einricht_id FROM knowledgecenter_category_einricht WHERE category_id = $categoryId";
|
||||
$resultSavedEinrichts = mysqli_query($GLOBALS['mysql_con'], $querySavedEinrichts);
|
||||
while ($row = mysqli_fetch_assoc($resultSavedEinrichts)) {
|
||||
$savedEinrichts[] = $row['einricht_id'];
|
||||
}
|
||||
|
||||
// Verknüpfungskarte für Einrichtungen (Gruppen → Mitglieder)
|
||||
$einrichtLinks = [];
|
||||
$linkRes = mysqli_query($GLOBALS['mysql_con'], "SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
|
||||
while ($lr = mysqli_fetch_assoc($linkRes)) {
|
||||
$einrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
|
||||
}
|
||||
|
||||
// Array mit Daten für Tagify (Einrichtungen)
|
||||
$savedEinrichtsData = [];
|
||||
foreach ($einrichts as $einricht) {
|
||||
if (in_array($einricht['id'], $savedEinrichts)) {
|
||||
$savedEinrichtsData[] = ['value' => $einricht['id'], 'name' => $einricht['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// Gespeicherte Fachbereiche abrufen
|
||||
$savedFachbereiche = [];
|
||||
$querySavedFachbereiche = "SELECT fachbereich_id FROM knowledgecenter_category_fachbereich WHERE category_id = $categoryId";
|
||||
$resultSavedFachbereiche = mysqli_query($GLOBALS['mysql_con'], $querySavedFachbereiche);
|
||||
while ($row = mysqli_fetch_assoc($resultSavedFachbereiche)) {
|
||||
$savedFachbereiche[] = $row['fachbereich_id'];
|
||||
}
|
||||
|
||||
// Array mit Daten für Tagify (Fachbereiche)
|
||||
$savedFachbereicheData = [];
|
||||
foreach ($fachbereiche as $fachbereich) {
|
||||
if (in_array($fachbereich['id'], $savedFachbereiche)) {
|
||||
$savedFachbereicheData[] = ['value' => $fachbereich['id'], 'name' => $fachbereich['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// Kategorie auslesen
|
||||
$query = "SELECT c.* FROM knowledgecenter_categories_update AS c WHERE c.id = $categoryId LIMIT 1";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$category = mysqli_fetch_assoc($result);
|
||||
|
||||
// Alle Sprachen aus dem System abrufen
|
||||
$languagesQuery = "SELECT id, code, name FROM main_language ORDER BY id ASC";
|
||||
$languagesResult = mysqli_query($GLOBALS['mysql_con'], $languagesQuery);
|
||||
$languages = [];
|
||||
while ($langRow = mysqli_fetch_assoc($languagesResult)) {
|
||||
$languages[] = $langRow;
|
||||
}
|
||||
|
||||
// Für jede Sprache die existierende Übersetzung abrufen
|
||||
$translations = [];
|
||||
foreach ($languages as $lang) {
|
||||
$langId = $lang['id'];
|
||||
$transQuery = "SELECT title, image, is_navigation_item
|
||||
FROM knowledgecenter_category_translations
|
||||
WHERE category_id = $categoryId AND language_id = $langId LIMIT 1";
|
||||
$transResult = mysqli_query($GLOBALS['mysql_con'], $transQuery);
|
||||
$translations[$lang['code']] = mysqli_fetch_assoc($transResult) ?: [
|
||||
'title' => '',
|
||||
'image' => '',
|
||||
'is_navigation_item' => 0
|
||||
];
|
||||
}
|
||||
|
||||
// Alle verknüpften Unterkategorien abrufen
|
||||
$linkedQuery = "SELECT c.id, t.title
|
||||
FROM knowledgecenter_category_links AS l
|
||||
JOIN knowledgecenter_categories_update AS c ON l.child_category_id = c.id
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON t.category_id = c.id AND t.language_id = $languageId
|
||||
WHERE l.parent_category_id = $categoryId
|
||||
ORDER BY l.sorting ASC";
|
||||
$linkedResult = mysqli_query($GLOBALS['mysql_con'], $linkedQuery);
|
||||
$subcategories = [];
|
||||
while ($row = mysqli_fetch_assoc($linkedResult)) {
|
||||
$subcategories[] = $row;
|
||||
}
|
||||
|
||||
// Erzeuge eine Liste der verknüpften IDs
|
||||
$selectedIds = [];
|
||||
foreach ($subcategories as $sub) {
|
||||
$selectedIds[] = $sub['id'];
|
||||
}
|
||||
$selectedIdsStr = empty($selectedIds) ? '0' : implode(',', $selectedIds);
|
||||
|
||||
// Alle verfügbaren Unterkategorien abrufen
|
||||
$availableQuery = "SELECT id,
|
||||
(SELECT title FROM knowledgecenter_category_translations
|
||||
WHERE category_id = knowledgecenter_categories_update.id
|
||||
AND language_id = $languageId LIMIT 1) as title
|
||||
FROM knowledgecenter_categories_update
|
||||
WHERE id NOT IN ($selectedIdsStr) AND id <> $categoryId
|
||||
ORDER BY title ASC";
|
||||
$availableResult = mysqli_query($GLOBALS['mysql_con'], $availableQuery);
|
||||
|
||||
// ***** Neuer Code: Überprüfen in wie vielen Parent-Kategorien die aktuelle Kategorie verknüpft wurde *****
|
||||
$parentQuery = "SELECT p.id, pt.title
|
||||
FROM knowledgecenter_category_links AS l
|
||||
JOIN knowledgecenter_categories_update AS p ON l.parent_category_id = p.id
|
||||
LEFT JOIN knowledgecenter_category_translations AS pt
|
||||
ON pt.category_id = p.id AND pt.language_id = $languageId
|
||||
WHERE l.child_category_id = $categoryId";
|
||||
$resultParents = mysqli_query($GLOBALS['mysql_con'], $parentQuery);
|
||||
$parentCategories = [];
|
||||
while ($row = mysqli_fetch_assoc($resultParents)) {
|
||||
$parentCategories[] = $row;
|
||||
}
|
||||
$parentCount = count($parentCategories);
|
||||
?>
|
||||
<style>
|
||||
.perm-row { margin-bottom: 16px; }
|
||||
.perm-row label { display: block; font-weight: 500; font-size: 13px; color: #374151; margin-bottom: 6px; }
|
||||
.perm-row .tagify { width: 100%; }
|
||||
</style>
|
||||
|
||||
<div class="cardform_containern">
|
||||
|
||||
<div class="cardform">
|
||||
<div class="card-body">
|
||||
<div class="tabbar_with_button">
|
||||
<a href="?action=Categories&embedded=<?= $isEmbedded ?>">←</a>
|
||||
<ul class="nav nav-tabs" id="languageTabs" role="tablist">
|
||||
<?php foreach ($languages as $index => $lang): ?>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link <?= ($index === 0) ? 'active' : '' ?>" id="tab-<?= $lang['code'] ?>"
|
||||
data-bs-toggle="tab" data-bs-target="#lang-<?= $lang['code'] ?>" type="button" role="tab">
|
||||
<?= htmlspecialchars(strtoupper($lang['code'])) ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div>
|
||||
<button id="message" style="opacity:0;"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content-languages" id="languageTabsContent">
|
||||
<?php foreach ($languages as $index => $lang):
|
||||
$trans = $translations[$lang['code']];
|
||||
?>
|
||||
<div class="tab-pane fade <?= ($index === 0) ? 'show active' : '' ?>" id="lang-<?= $lang['code'] ?>"
|
||||
role="tabpanel">
|
||||
<form class="translationForm" data-lang="<?= $lang['id'] ?>" data-lang-code="<?= $lang['code'] ?>">
|
||||
<div class="label title-<?php echo $lang['code']; ?>">
|
||||
<label for="title-<?php echo $lang['code']; ?>">Titel
|
||||
(<?php echo htmlspecialchars($lang['code']); ?>)</label>
|
||||
</div>
|
||||
<div class="input title-<?php echo $lang['code']; ?>">
|
||||
<input required name="title-<?php echo $lang['code']; ?>"
|
||||
id="title-<?php echo $lang['code']; ?>" class="text" type="text"
|
||||
value="<?php echo htmlspecialchars($translations[$lang['code']]['title']); ?>">
|
||||
</div>
|
||||
<?php if ($categoryId != 0): ?>
|
||||
<div class="single_image_group">
|
||||
<img id="preview-<?= $lang['code'] ?>"
|
||||
src="<?= $trans['image'] ? '/userdata/' . htmlspecialchars($trans['image']) : '/userdata/intranet/mandant/platzhalter_kategorie.png' ?>"
|
||||
alt="Bildvorschau" style="max-width: 100px; min-height: 60px;
|
||||
border-radius: 5px; <?= $trans['image'] ? '' : 'background-color:#f3f3f3;' ?>">
|
||||
<input type="hidden" name="image-<?= $lang['code'] ?>" id="image-<?= $lang['code'] ?>"
|
||||
value="<?= htmlspecialchars($trans['image']) ?>">
|
||||
<div class="post-action-btn green openFileGallery" data-ajax-function="saveCategoryAjax"
|
||||
data-target="image-<?= $lang['code'] ?>">
|
||||
+</div>
|
||||
<div class="post-action-btn clearImage" data-target="image-<?= $lang['code'] ?>">✕</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($categoryId != 0): ?>
|
||||
<div class="category_sub_select">
|
||||
<div class="category_sub_select_container">
|
||||
<h3><?= $translation->get("connected_subcategories") ?></h3>
|
||||
<input type="search" class="subcategory-search" id="search-selected"
|
||||
placeholder="Suche in verknüpften Kategorien">
|
||||
<ul id="selectedSubcategories" class="list-group connectedSortable">
|
||||
<?php if (empty($subcategories)): ?>
|
||||
<li class="list-group-item empty-hint">
|
||||
<?= $translation->get("no_connected_subcategories") ?>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php foreach ($subcategories as $sub): ?>
|
||||
<li class="list-group-item" data-id="<?= $sub['id'] ?>">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-grip-vertical" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
|
||||
</svg>
|
||||
<?= htmlspecialchars($sub['title'] ?? "Fehlende Übersetzung ID {$sub['id']}") ?>
|
||||
</div>
|
||||
<button title="<?= $translation->get("delete") ?>" type="button"
|
||||
class="unlinkSubcategory">✕</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="category_sub_select_container">
|
||||
<h3><?= $translation->get("available_categories") ?></h3>
|
||||
<input type="search" class="subcategory-search" id="search-available"
|
||||
placeholder="Suche in verfügbaren Kategorien">
|
||||
<ul id="availableSubcategories" class="list-group connectedSortable">
|
||||
<?php if (mysqli_num_rows($availableResult) == 0): ?>
|
||||
<li class="list-group-item empty-hint">
|
||||
<?= $translation->get("all_available_categories_used") ?>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php while ($row = mysqli_fetch_assoc($availableResult)): ?>
|
||||
<li class="list-group-item" data-id="<?= $row['id'] ?>">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-grip-vertical" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
|
||||
</svg>
|
||||
<?= htmlspecialchars($row['title'] ?: "Fehlende Übersetzung ID {$row['id']}") ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endwhile; ?>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aside">
|
||||
<div class="aside_section">
|
||||
<div class="aside_toolbar">
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||
<button class="post-action-btn" id="saveAllTranslations" style="background: #14b814;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-check" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<?php } ?>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||
<button class="post-action-btn red" id="deleteCategory">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-trash3-fill" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M11 1.5v1h3.5a.5.5 0 0 1 0 1h-.538l-.853 10.66A2 2 0 0 1 11.115 16h-6.23a2 2 0 0 1-1.994-1.84L2.038 3.5H1.5a.5.5 0 0 1 0-1H5v-1A1.5 1.5 0 0 1 6.5 0h3A1.5 1.5 0 0 1 11 1.5m-5 0v1h4v-1a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5M4.5 5.029l.5 8.5a.5.5 0 1 0 .998-.06l-.5-8.5a.5.5 0 1 0-.998.06m6.53-.528a.5.5 0 0 0-.528.47l-.5 8.5a.5.5 0 0 0 .998.058l.5-8.5a.5.5 0 0 0-.47-.528M8 4.5a.5.5 0 0 0-.5.5v8.5a.5.5 0 0 0 1 0V5a.5.5 0 0 0-.5-.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aside_section">
|
||||
<label for="stateCheckbox">
|
||||
<?= $translation->get("active_state") ?>
|
||||
<input type="checkbox" id="stateCheckbox" name="state" value="1" <?= $state === 1 ? 'checked' : '' ?>>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php $is_navigation_item = $translations[$languages[0]['code']]['is_navigation_item'] ?? 0; ?>
|
||||
<div class="aside_section">
|
||||
<label for="isNavigationItem">
|
||||
<?= $translation->get("create_navigation_menu") ?>
|
||||
<input type="checkbox" id="isNavigationItem" name="is_navigation_item" value="1"
|
||||
<?= $is_navigation_item == 1 ? 'checked' : '' ?>>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="aside_section">
|
||||
<label for="color_hex">
|
||||
<?= $translation->get("color_hex") ?>
|
||||
<input type="color" id="color_hex" name="color_hex" value="<?= $category['color_hex'] ?? '#3B82F6FF' ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php $enable_post_color_pickers = isset($category['enable_post_color_pickers']) ? (int) $category['enable_post_color_pickers'] : 0; ?>
|
||||
<div class="aside_section">
|
||||
<label for="enablePostColorPickersCheckbox">
|
||||
<?= $translation->get("enable_post_color_pickers") ?>
|
||||
<input type="checkbox" id="enablePostColorPickersCheckbox" name="enable_post_color_pickers" value="1"
|
||||
<?= $enable_post_color_pickers === 1 ? 'checked' : '' ?>>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php $betriebsrat_editable = isset($category['betriebsrat_editable']) ? (int) $category['betriebsrat_editable'] : 0; ?>
|
||||
<div class="aside_section">
|
||||
<label for="betriebsratEditableCheckbox">
|
||||
Für Betriebsrat bearbeitbar
|
||||
<input type="checkbox" id="betriebsratEditableCheckbox" name="betriebsrat_editable" value="1"
|
||||
<?= $betriebsrat_editable === 1 ? 'checked' : '' ?>>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($categoryId)): ?>
|
||||
<?php
|
||||
$formatter = new IntlDateFormatter(
|
||||
'de_DE',
|
||||
IntlDateFormatter::LONG,
|
||||
IntlDateFormatter::SHORT
|
||||
);
|
||||
$timestamp = strtotime($category["modified_at"]);
|
||||
?>
|
||||
<div class="aside_section">
|
||||
<small><?= $translation->get("connected_subcategories") ?> (<?= $parentCount ?>)</small>
|
||||
<?php if ($parentCount > 0): ?>
|
||||
<ul class="connected_categories">
|
||||
<?php foreach ($parentCategories as $parent): ?>
|
||||
<li>
|
||||
<a target="_blank" href="?action=Categories&detail=<?= $parent['id'] ?>">
|
||||
<?= htmlspecialchars($parent['title'] ?: "Kategorie-ID " . $parent['id']) ?> ↗
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p style="font-style: italic;"><?= $translation->get("not_connected_as_subcategory") ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="aside_section">
|
||||
<small><?= $translation->get("last_change_on") ?></small>
|
||||
<p style="font-style: italic;">
|
||||
<?= $formatter->format($timestamp) ?> - <?= $category["modified_by"] ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($categoryId != 0): ?>
|
||||
<div class="cardform_containern perm-fullwidth">
|
||||
<div class="cardform" style="flex:1;">
|
||||
<div class="card-body">
|
||||
<h3><?= $translation->get("permissions_and_filters") ?></h3>
|
||||
<div class="perm-row">
|
||||
<label for="selected_mandants"><?= $translation->get("mandants") ?></label>
|
||||
<input type="text" id="selected_mandants" name="selected_mandants"
|
||||
value='<?= htmlspecialchars(json_encode($savedMandantsData)); ?>'>
|
||||
</div>
|
||||
<div class="perm-row">
|
||||
<label for="selected_departments"><?= $translation->get("departments") ?></label>
|
||||
<input type="text" id="selected_departments" name="selected_departments"
|
||||
value='<?= htmlspecialchars(json_encode($savedDepartmentsData)); ?>'>
|
||||
</div>
|
||||
<div class="perm-row">
|
||||
<label for="selected_roles"><?= $translation->get("roles") ?></label>
|
||||
<input type="text" id="selected_roles" name="selected_roles"
|
||||
value='<?= htmlspecialchars(json_encode($savedRolesData)); ?>'>
|
||||
</div>
|
||||
<div class="perm-row">
|
||||
<label for="selected_einrichts">Einrichtungen</label>
|
||||
<input type="text" id="selected_einrichts" name="selected_einrichts"
|
||||
value='<?= htmlspecialchars(json_encode($savedEinrichtsData)); ?>'>
|
||||
</div>
|
||||
<div class="perm-row">
|
||||
<label for="selected_fachbereiche">Fachbereiche</label>
|
||||
<input type="text" id="selected_fachbereiche" name="selected_fachbereiche"
|
||||
value='<?= htmlspecialchars(json_encode($savedFachbereicheData)); ?>'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" id="category_id" value="<?= $categoryId ?>">
|
||||
<input type="hidden" id="modified_by"
|
||||
value="<?= $GLOBALS["main_contact"]["name"] ?> (<?= $GLOBALS["main_contact"]["id"] ?>)">
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
// Funktion zum sequentiellen Speichern der Übersetzungen
|
||||
window.saveCategoryAjax = function (callback) {
|
||||
var forms = $(".translationForm");
|
||||
var totalForms = forms.length;
|
||||
var currentIndex = 0;
|
||||
|
||||
function processNextForm() {
|
||||
if (currentIndex >= totalForms) {
|
||||
if (callback) callback();
|
||||
return; // Alle Formulare wurden bearbeitet
|
||||
}
|
||||
|
||||
var $form = $(forms[currentIndex]);
|
||||
var langId = $form.data("lang");
|
||||
var langCode = $form.data("lang-code");
|
||||
var title = $form.find("input[name='title-" + langCode + "']").val();
|
||||
var image = $form.find("input[name='image-" + langCode + "']").val();
|
||||
var state = $('#stateCheckbox').is(':checked') ? 1 : 0;
|
||||
var isNavigationItem = $('#isNavigationItem').is(':checked') ? 1 : 0;
|
||||
var enablePostColorPickers = $('#enablePostColorPickersCheckbox').is(':checked') ? 1 : 0;
|
||||
var betriebsratEditable = $('#betriebsratEditableCheckbox').is(':checked') ? 1 : 0;
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_category_detail",
|
||||
id: $("#category_id").val(),
|
||||
title: title,
|
||||
image: image,
|
||||
language_id: langId,
|
||||
modified_by: $("#modified_by").val(),
|
||||
state: state,
|
||||
is_navigation_item: isNavigationItem,
|
||||
enable_post_color_pickers: enablePostColorPickers,
|
||||
color_hex: $('#color_hex').val(),
|
||||
betriebsrat_editable: betriebsratEditable
|
||||
}, function (response) {
|
||||
if (!response.success) {
|
||||
showMessage("Fehler beim Speichern der Übersetzung (" + langCode + "): " + response.error, false);
|
||||
} else {
|
||||
var newImagePath = $("input[name='image-" + langCode + "']").val();
|
||||
var $previewImg = $("#preview-" + langCode);
|
||||
if (newImagePath) {
|
||||
$previewImg.attr("src", "/userdata/" + newImagePath).show();
|
||||
}
|
||||
// Wenn neuer Datensatz, ID setzen und URL anpassen
|
||||
if ($("#category_id").val() == 0 && response.category_id) {
|
||||
$("#category_id").val(response.category_id);
|
||||
history.replaceState({}, '', "?action=Categories&detail=" + response.category_id + "&embedded=<?= htmlspecialchars($isEmbedded, ENT_QUOTES, 'UTF-8') ?>");
|
||||
location.reload();
|
||||
}
|
||||
|
||||
showMessage("Erfolgreich gespeichert", true);
|
||||
}
|
||||
currentIndex++;
|
||||
processNextForm(); // Nächsten Request starten
|
||||
}, "json");
|
||||
}
|
||||
processNextForm();
|
||||
}
|
||||
|
||||
// Funktion zum Speichern der Tagify-Daten (Mandanten, Departments, Rollen, Einrichtungen, Fachbereiche)
|
||||
function saveTagifyData() {
|
||||
var mandants = JSON.stringify(tagifyMandants.value.map(function (t) { return t.value; }));
|
||||
var departments = JSON.stringify(tagifyDepartments.value.map(function (t) { return t.value; }));
|
||||
var roles = JSON.stringify(tagifyRoles.value.map(function (t) { return t.value; }));
|
||||
var einrichts = JSON.stringify(tagifyEinrichts.value.map(function (t) { return t.value; }));
|
||||
var fachbereiche = JSON.stringify(tagifyFachbereiche.value.map(function (t) { return t.value; }));
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_category_links",
|
||||
id: $("#category_id").val(),
|
||||
mandants: mandants,
|
||||
departments: departments,
|
||||
roles: roles,
|
||||
einrichts: einrichts,
|
||||
fachbereiche: fachbereiche,
|
||||
modified_by: $("#modified_by").val()
|
||||
}, function (response) {
|
||||
if (!response.success) {
|
||||
showMessage("Fehler beim Speichern der Verknüpfungen: " + response.error, false);
|
||||
} else {
|
||||
tagifyMandants.removeAllTags();
|
||||
tagifyMandants.addTags(response.mandants);
|
||||
|
||||
tagifyDepartments.removeAllTags();
|
||||
tagifyDepartments.addTags(response.departments);
|
||||
|
||||
tagifyRoles.removeAllTags();
|
||||
tagifyRoles.addTags(response.roles);
|
||||
|
||||
tagifyEinrichts.removeAllTags();
|
||||
tagifyEinrichts.addTags(response.einrichts);
|
||||
|
||||
tagifyFachbereiche.removeAllTags();
|
||||
tagifyFachbereiche.addTags(response.fachbereiche);
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
|
||||
// Handler für "Speichern"-Button
|
||||
$("#saveAllTranslations").on("click", function () {
|
||||
saveCategoryAjax();
|
||||
<?php if ($categoryId != 0): ?>
|
||||
saveTagifyData();
|
||||
<?php endif; ?>
|
||||
});
|
||||
|
||||
// Sortable-Initialisierung für beide Unterkategorie-Listen
|
||||
$("#selectedSubcategories, #availableSubcategories").sortable({
|
||||
connectWith: ".connectedSortable",
|
||||
placeholder: "ui-state-highlight",
|
||||
update: function (event, ui) {
|
||||
if ($(this).attr("id") === "selectedSubcategories") {
|
||||
var sortedIDs = $(this).sortable("toArray", { attribute: "data-id" });
|
||||
$.ajax({
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
data: {
|
||||
action: "update_subcategory_sorting",
|
||||
parent_id: <?= $categoryId ?>,
|
||||
order: sortedIDs
|
||||
},
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Sortierung aktualisiert!", true);
|
||||
} else {
|
||||
showMessage("Fehler beim Sortieren: " + response.error, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
receive: function (event, ui) {
|
||||
// Wird ein Element in die linke Liste (Ausgewählt) gezogen
|
||||
if ($(this).attr("id") === "selectedSubcategories") {
|
||||
var childId = ui.item.data("id");
|
||||
if (ui.item.find(".unlinkSubcategory").length === 0) {
|
||||
ui.item.append('<button type="button" class="unlinkSubcategory">✕</button>');
|
||||
}
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "link_subcategory",
|
||||
parent_id: <?= $categoryId ?>,
|
||||
child_id: childId
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Kategorie verknüpft!", true);
|
||||
updateEmptyHints();
|
||||
} else {
|
||||
showMessage("Fehler beim Verknüpfen: " + response.error, false);
|
||||
updateEmptyHints();
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
// Wird ein Element in die rechte Liste (Verfügbar) gezogen
|
||||
if ($(this).attr("id") === "availableSubcategories") {
|
||||
var childId = ui.item.data("id");
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "unlink_subcategory",
|
||||
parent_id: <?= $categoryId ?>,
|
||||
child_id: childId
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Kategorie entfernt!", true);
|
||||
} else {
|
||||
showMessage("Fehler beim Entfernen: " + response.error, false);
|
||||
}
|
||||
}, "json");
|
||||
ui.item.find(".unlinkSubcategory").remove();
|
||||
}
|
||||
}
|
||||
}).disableSelection();
|
||||
|
||||
// Event-Handler für das Entfernen einer Unterkategorie per Klick
|
||||
$("#selectedSubcategories").on("click", ".unlinkSubcategory", function (e) {
|
||||
e.stopPropagation();
|
||||
var li = $(this).closest("li");
|
||||
var childId = li.data("id");
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "unlink_subcategory",
|
||||
parent_id: <?= $categoryId ?>,
|
||||
child_id: childId
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
li.fadeOut(function () {
|
||||
li.find(".unlinkSubcategory").remove();
|
||||
li.appendTo("#availableSubcategories").fadeIn();
|
||||
showMessage("Kategorie entfernt!", true);
|
||||
updateEmptyHints();
|
||||
});
|
||||
} else {
|
||||
showMessage("Fehler beim Entfernen: " + response.error, false);
|
||||
updateEmptyHints();
|
||||
}
|
||||
}, "json");
|
||||
});
|
||||
|
||||
// Suche für verknüpfte Unterkategorien
|
||||
$("#search-selected").on("keyup", function () {
|
||||
var value = $(this).val().toLowerCase();
|
||||
$("#selectedSubcategories li").filter(function () {
|
||||
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
|
||||
});
|
||||
});
|
||||
|
||||
// Suche für verfügbare Kategorien
|
||||
$("#search-available").on("keyup", function () {
|
||||
var value = $(this).val().toLowerCase();
|
||||
$("#availableSubcategories li").filter(function () {
|
||||
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
// Stelle sicher, dass der erste Tab und das zugehörige Content-Panel aktiv sind
|
||||
$('#languageTabs button').first().addClass('active');
|
||||
$('#languageTabsContent .tab-pane').first().addClass('show active');
|
||||
|
||||
// Bei Tab-Klick: Aktive Klasse wechseln
|
||||
$('#languageTabs button').on('click', function () {
|
||||
var target = $(this).attr('data-bs-target');
|
||||
$('#languageTabs button').removeClass('active');
|
||||
$('#languageTabsContent .tab-pane').removeClass('show active');
|
||||
$(this).addClass('active');
|
||||
$(target).addClass('show active');
|
||||
});
|
||||
});
|
||||
|
||||
// Funktion zur Aktualisierung der leeren Hinweistexte in den Unterkategorie-Listen
|
||||
function updateEmptyHints() {
|
||||
const selected = $("#selectedSubcategories");
|
||||
const available = $("#availableSubcategories");
|
||||
|
||||
if (selected.find("li:not(.empty-hint)").length === 0) {
|
||||
if (selected.find(".empty-hint").length === 0) {
|
||||
selected.append('<li class="list-group-item empty-hint">Aktuell wurde noch keine Unterkategorie verknüpft. Ziehen Sie einfach per Drag & Drop eine Kategorie in das weiße Feld.</li>');
|
||||
}
|
||||
} else {
|
||||
selected.find(".empty-hint").remove();
|
||||
}
|
||||
|
||||
if (available.find("li:not(.empty-hint)").length === 0) {
|
||||
if (available.find(".empty-hint").length === 0) {
|
||||
available.append('<li class="list-group-item empty-hint">Alle möglichen Kategorien wurden bereits verknüpft.</li>');
|
||||
}
|
||||
} else {
|
||||
available.find(".empty-hint").remove();
|
||||
}
|
||||
}
|
||||
|
||||
<?php if ($categoryId != 0): ?>
|
||||
// Initialisierung der Tagify-Objekte
|
||||
var mandantsData = <?php echo json_encode(array_map(function ($item) {
|
||||
return ['value' => $item['id'], 'name' => $item['description']];
|
||||
}, $mandants)); ?>;
|
||||
|
||||
var departmentsData = <?php echo json_encode(array_map(function ($item) {
|
||||
return ['value' => $item['id'], 'name' => $item['description']];
|
||||
}, $departments)); ?>;
|
||||
|
||||
var rolesData = <?php echo json_encode(array_map(function ($item) {
|
||||
return ['value' => $item['id'], 'name' => $item['description']];
|
||||
}, $roles)); ?>;
|
||||
|
||||
var tagifyMandants = new Tagify(document.getElementById('selected_mandants'), {
|
||||
whitelist: mandantsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
closeOnSelect: false,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)}
|
||||
class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var tagifyDepartments = new Tagify(document.getElementById('selected_departments'), {
|
||||
whitelist: departmentsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)}
|
||||
class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var tagifyRoles = new Tagify(document.getElementById('selected_roles'), {
|
||||
whitelist: rolesData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)}
|
||||
class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var einrichtsData = <?php echo json_encode(array_map(function ($item) {
|
||||
return ['value' => $item['id'], 'name' => $item['description']];
|
||||
}, $einrichts)); ?>;
|
||||
|
||||
var fachbereicheData = <?php echo json_encode(array_map(function ($item) {
|
||||
return ['value' => $item['id'], 'name' => $item['description']];
|
||||
}, $fachbereiche)); ?>;
|
||||
|
||||
var tagifyEinrichts = new Tagify(document.getElementById('selected_einrichts'), {
|
||||
whitelist: einrichtsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)}
|
||||
class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
var einrichtLinks = <?= json_encode($einrichtLinks) ?>;
|
||||
tagifyEinrichts.on('add', function(e) {
|
||||
var addedId = String(e.detail.data.value);
|
||||
(einrichtLinks[addedId] || []).forEach(function(lid) {
|
||||
var exists = tagifyEinrichts.value.some(function(t) { return String(t.value) === lid; });
|
||||
if (!exists) {
|
||||
var item = einrichtsData.find(function(d) { return String(d.value) === lid; });
|
||||
if (item) tagifyEinrichts.addTags([item]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var tagifyFachbereiche = new Tagify(document.getElementById('selected_fachbereiche'), {
|
||||
whitelist: fachbereicheData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)}
|
||||
class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
// Handler für das Löschen einer Kategorie
|
||||
$("#deleteCategory").on("click", function () {
|
||||
if (confirm("Möchten Sie diese Kategorie wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) {
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "delete_category",
|
||||
id: $("#category_id").val()
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Kategorie erfolgreich gelöscht!", true);
|
||||
window.location.href = "?action=Categories&embedded=<?= $isEmbedded ?>";
|
||||
} else {
|
||||
showMessage("Fehler beim Löschen: " + response.error, false);
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
190
module/knowledgecenter_update/Views/categories_listform.php
Normal file
190
module/knowledgecenter_update/Views/categories_listform.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
// ------------------------------
|
||||
// Category List Form mit Unterkategorien und Sortable
|
||||
// ------------------------------
|
||||
|
||||
$formname = "categories_listform";
|
||||
|
||||
/**
|
||||
* Holt alle Hauptkategorien mit Übersetzung und verknüpften Unterkategorien
|
||||
*/
|
||||
function fetchCategories($languageId)
|
||||
{
|
||||
$query =
|
||||
"SELECT
|
||||
c.id,
|
||||
t.title,
|
||||
c.sorting,
|
||||
c.state,
|
||||
GROUP_CONCAT(
|
||||
CONCAT(sc.id,'::',st.title,'::',sc.state)
|
||||
ORDER BY l.sorting ASC
|
||||
SEPARATOR '||'
|
||||
) AS subcats_data
|
||||
FROM knowledgecenter_categories_update AS c
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON t.category_id = c.id AND t.language_id = $languageId
|
||||
LEFT JOIN knowledgecenter_category_links AS l
|
||||
ON l.parent_category_id = c.id
|
||||
LEFT JOIN knowledgecenter_categories_update AS sc
|
||||
ON sc.id = l.child_category_id
|
||||
LEFT JOIN knowledgecenter_category_translations AS st
|
||||
ON st.category_id = sc.id AND st.language_id = $languageId
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sorting ASC";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$categories = [];
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
// Unterkategorien parsen
|
||||
$row['subcategories'] = [];
|
||||
if (!empty($row['subcats_data'])) {
|
||||
foreach (explode('||', $row['subcats_data']) as $part) {
|
||||
list($id, $title, $state) = explode('::', $part, 3);
|
||||
$row['subcategories'][] = [
|
||||
'id' => (int) $id,
|
||||
'title' => $title,
|
||||
'state' => (int) $state
|
||||
];
|
||||
}
|
||||
}
|
||||
$categories[] = $row;
|
||||
}
|
||||
return $categories;
|
||||
}
|
||||
|
||||
// Kategorien abrufen
|
||||
$categories = fetchCategories($languageId);
|
||||
?>
|
||||
|
||||
<form action="" id="<?= $formname ?>" name="<?= $formname ?>" method="post">
|
||||
<div class="titlebar">
|
||||
<div>
|
||||
<?php if (!$isEmbedded): ?>
|
||||
<a title="<?= $translation->get("back") ?>" href="?action=List">←</a>
|
||||
<?php endif; ?>
|
||||
<h2><?= $translation->get("category_list") ?></h2>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
|
||||
<span title="<?= $translation->get("new_wiki_category") ?>" id="btnNewCategory"
|
||||
class="post-action-btn green">+</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<button style="opacity:0;" id="message"></button>
|
||||
<div>
|
||||
<input type="search" id="searchCategories" placeholder="<?= $translation->get("search_placeholder") ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="sortable" class="list-group">
|
||||
<?php foreach ($categories as $cat): ?>
|
||||
<li data-id="<?= $cat['id'] ?>">
|
||||
<div>
|
||||
<!-- Grip-Icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-grip-vertical" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
|
||||
</svg>
|
||||
<span class="status <?= $cat['state'] == 1 ? 'active' : 'inactive' ?>">
|
||||
</span>
|
||||
<?= htmlspecialchars($cat['title'] ?? "Kein Inhalt für diese Sprache hinterlegt ID: " . $cat['id']) ?>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
|
||||
<span title="<?= $translation->get("delete") ?>"
|
||||
class="deleteCategory post-action-btn-small red">×</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($cat['subcategories'])): ?>
|
||||
<ul class="subcat-list">
|
||||
<?php foreach ($cat['subcategories'] as $sub): ?>
|
||||
<li class="subcategory-item" data-id="<?= $sub['id'] ?>">
|
||||
<?= htmlspecialchars($sub['title']) ?>
|
||||
<span class="status <?= $sub['state'] == 1 ? 'active' : 'inactive' ?>">
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
// Suche in Haupt- und Unterkategorien
|
||||
$("#searchCategories").on("input", function () {
|
||||
var query = $(this).val().toLowerCase();
|
||||
$("#sortable li").each(function () {
|
||||
var text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.indexOf(query) !== -1);
|
||||
});
|
||||
});
|
||||
|
||||
// Nur Top-Level li sortierbar
|
||||
$("#sortable").sortable({
|
||||
items: "> li",
|
||||
cancel: ".subcat-list",
|
||||
update: function (event, ui) {
|
||||
var sortedIDs = $(this).sortable("toArray", { attribute: "data-id" });
|
||||
$.ajax({
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
data: {
|
||||
action: "update_categories_sorting",
|
||||
order: sortedIDs
|
||||
}
|
||||
})
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Sortierung erfolgreich gespeichert", true);
|
||||
} else {
|
||||
showMessage("Fehler: " + response.error, false);
|
||||
}
|
||||
})
|
||||
.fail(function (jqXHR, textStatus, errorThrown) {
|
||||
showMessage("Serverfehler beim Speichern: " + textStatus, false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Doppelklick auf Hauptkategorie
|
||||
$("#sortable").on("dblclick", "> li", function () {
|
||||
var id = $(this).data("id");
|
||||
window.location.href = "?action=Categories&detail=" + id + "&embedded=<?= $isEmbedded ?>";
|
||||
});
|
||||
|
||||
// Doppelklick auf Unterkategorie
|
||||
$("#sortable").on("dblclick", ".subcategory-item", function (e) {
|
||||
e.stopPropagation();
|
||||
var id = $(this).data("id");
|
||||
window.location.href = "?action=Categories&detail=" + id + "&embedded=<?= $isEmbedded ?>";
|
||||
});
|
||||
|
||||
// Neu-Kategorie-Button
|
||||
$("#btnNewCategory").on("click", function () {
|
||||
window.location.href = "?action=Categories&detail=new&embedded=<?= $isEmbedded ?>";
|
||||
});
|
||||
|
||||
// Löschen einer Kategorie
|
||||
$("#sortable").on("click", ".deleteCategory", function (e) {
|
||||
e.stopPropagation();
|
||||
var li = $(this).closest("li");
|
||||
var categoryId = li.data("id");
|
||||
if (confirm("Möchten Sie diese Kategorie wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.")) {
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "delete_category",
|
||||
id: categoryId
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Kategorie erfolgreich gelöscht!", true);
|
||||
li.fadeOut(function () { li.remove(); });
|
||||
} else {
|
||||
showMessage("Fehler beim Löschen: " + response.error, false);
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
// ini_set('display_errors', 1);
|
||||
// ini_set('display_startup_errors', 1);
|
||||
// error_reporting(E_ALL);
|
||||
|
||||
if (empty($GLOBALS['allowedMandants'])) {
|
||||
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
|
||||
<strong>Hinweis:</strong> Sie haben keine Mandantenzuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
|
||||
</div>";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($GLOBALS['allowedDepartments'])) {
|
||||
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
|
||||
<strong>Hinweis:</strong> Sie haben keine Abteilungszuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
|
||||
</div>";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($GLOBALS['allowedRoles'])) {
|
||||
echo "<div style='padding:1em; background:#ffe0e0; border:1px solid red; color:#a00000;'>
|
||||
<strong>Hinweis:</strong> Sie haben keine Rollen-Zuweisung. Bitte wenden Sie sich an Ihren Intranet-Administrator.
|
||||
</div>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Standard-Redirect-URL (zum Beispiel für Navigation)
|
||||
$redirectURL = isset($_GET['redirectURL']) ? $_GET['redirectURL'] : '?action=List';
|
||||
|
||||
$_kcBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
|
||||
?>
|
||||
<script>
|
||||
(function () {
|
||||
var mode = localStorage.getItem("categoryGridView");
|
||||
if (mode === "list") {
|
||||
document.documentElement.classList.add("list_view");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<form action="" id="<?= $formname = "categories_posts_listform" ?>" name="<?= $formname ?>" method="post">
|
||||
<div class="titlebar">
|
||||
<div class="searchbar">
|
||||
<?php if (has_wiki_edit_permission()) { ?>
|
||||
<?= button("post_list", $translation->get("post_list"), $formname, "sendRequest('PostList', true)"); ?>
|
||||
<?php } ?>
|
||||
<?php if (is_wiki_redakteur()) { ?>
|
||||
<?= button("category_list", $translation->get("category_list"), $formname, "sendRequest('Categories', true)"); ?>
|
||||
<?php } ?>
|
||||
<div id="changeCategoryGridView">
|
||||
</div>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<?php
|
||||
// "Beitrag erstellen" für Redakteur immer, für Betriebsrat nur in freigegebenen Kategorien
|
||||
// (inkl. Vererbung: Unterkategorien einer freigegebenen Kategorie gelten ebenfalls)
|
||||
$showCreateBtn = false;
|
||||
if (is_wiki_redakteur()) {
|
||||
$showCreateBtn = true;
|
||||
} elseif ($_kcBrOnly && isset($_GET['detail'])) {
|
||||
$showCreateBtn = is_category_betriebsrat_editable((int)$_GET['detail']);
|
||||
}
|
||||
if ($showCreateBtn) { ?>
|
||||
<a href="?action=NewPost&InitialCategory=<?= (int)($_GET["detail"] ?? 0) ?>"
|
||||
title="<?= $translation->get("new_wiki_post") ?>" role="button" class="green">Beitrag erstellen +</a>
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="searchResults"></div>
|
||||
|
||||
<?php
|
||||
$placeholderCategory = '/userdata/01_Basic/platzhalter_bild_kategorie.jpg';
|
||||
$placeholderPost = '/userdata/01_Basic/platzhalter_bild.jpg';
|
||||
|
||||
if (isset($_GET['detail'])) {
|
||||
// Detailansicht einer Kategorie:
|
||||
$categoryId = (int) $_GET['detail'];
|
||||
$sql = "SELECT c.*, t.title, t.image
|
||||
FROM knowledgecenter_categories_update AS c
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON c.id = t.category_id AND t.language_id = $languageId
|
||||
WHERE c.id = $categoryId LIMIT 1";
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
if ($res && mysqli_num_rows($res) > 0) {
|
||||
$category = mysqli_fetch_assoc($res);
|
||||
if (empty($category['is_single_page']) || (int) $category['is_single_page'] !== 1) {
|
||||
echo "<div class='category_page_titlebar'>";
|
||||
echo '<a title="' . $translation->get('back') . '" href="' . htmlspecialchars($redirectURL, ENT_QUOTES, 'UTF-8') . '">←</a>';
|
||||
echo "<div class='category_page_title'>" . htmlspecialchars($category['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
|
||||
echo "</div>";
|
||||
}
|
||||
}
|
||||
echo "<script>window.kcCategoryTitle = " . json_encode(isset($category['title']) && $category['title'] !== '' ? $category['title'] : null) . ";</script>";
|
||||
|
||||
// Dynamische Sortierung für Posts abhängig von c.order_posts_by (Whitelist)
|
||||
$orderBy = 'pv.title';
|
||||
$orderDir = 'ASC';
|
||||
if (!empty($category['order_posts_by'])) {
|
||||
switch (trim($category['order_posts_by'])) {
|
||||
case 'created_at':
|
||||
$orderBy = 'kp.created_at';
|
||||
$orderDir = 'DESC'; // bei Bedarf auf DESC ändern
|
||||
break;
|
||||
case 'modified_at':
|
||||
$orderBy = 'pv.modified_at';
|
||||
$orderDir = 'DESC'; // bei Bedarf auf DESC ändern
|
||||
break;
|
||||
case 'title':
|
||||
$orderBy = 'pv.title';
|
||||
$orderDir = 'ASC';
|
||||
break;
|
||||
// weitere erlaubte Felder können hier explizit aufgenommen werden
|
||||
default:
|
||||
// unbekannter Wert -> Default auf Titel
|
||||
$orderBy = 'pv.title';
|
||||
$orderDir = 'ASC';
|
||||
}
|
||||
}
|
||||
|
||||
$extraGridClass = (isset($category['is_single_page']) && (int) $category['is_single_page'] === 1) ? ' single_page' : ' multi_page';
|
||||
echo "<div class='categories_grid{$extraGridClass}'>";
|
||||
|
||||
/**
|
||||
* Unterkategorien: Wir verwenden LEFT JOINs, um zu filtern.
|
||||
* Es wird geprüft, ob in der Abteilungstabelle (cd), Mandantentabelle (cm) und Rollentabelle (cr)
|
||||
* entweder kein Datensatz vorliegt (NULL) oder ob der Wert in den erlaubten Werten enthalten ist.
|
||||
*/
|
||||
$sqlSubs = "
|
||||
SELECT
|
||||
c.id,
|
||||
t.title,
|
||||
t.description,
|
||||
t.image
|
||||
FROM
|
||||
knowledgecenter_category_links AS kcl
|
||||
JOIN
|
||||
knowledgecenter_categories_update AS c ON kcl.child_category_id = c.id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_department AS cd ON c.id = cd.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_role AS cr ON c.id = cr.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
|
||||
WHERE
|
||||
kcl.parent_category_id = $categoryId
|
||||
AND c.state = 1
|
||||
AND ( cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments) )
|
||||
AND ( cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants) )
|
||||
AND ( cr.role_id IS NULL OR cr.role_id IN ($allowedRoles) )
|
||||
AND ( ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts) )
|
||||
AND ( cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche) )
|
||||
GROUP BY
|
||||
c.id
|
||||
ORDER BY
|
||||
kcl.sorting ASC
|
||||
";
|
||||
$resSubs = mysqli_query($GLOBALS['mysql_con'], $sqlSubs);
|
||||
if ($resSubs && mysqli_num_rows($resSubs) > 0) {
|
||||
while ($sub = mysqli_fetch_assoc($resSubs)) {
|
||||
$route = isset($_GET['route']) ? $_GET['route'] : '';
|
||||
$new_route = $route ? $route . ',' . $category['id'] : $category['id'];
|
||||
|
||||
$currentURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
echo "<a class='post_wiki category_post' href=\"?detail={$sub['id']}&redirectURL={$currentURL}&route={$new_route}\">";
|
||||
echo "<div class='subcategory'>";
|
||||
$imgSrc = !empty($sub['image']) ? '/userdata/' . htmlspecialchars($sub['image']) : $placeholderCategory;
|
||||
echo "<img src='" . $imgSrc . "' alt='Subkategorie Bild'>";
|
||||
echo "<div class='card_title'>" . htmlspecialchars($sub['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
|
||||
if (!empty($sub['description'])) {
|
||||
echo "<div class='card_description'>" . htmlspecialchars($sub['description']) . "</div>";
|
||||
}
|
||||
echo "</div>";
|
||||
echo "</a>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beiträge, die der ausgewählten Kategorie zugeordnet sind, werden ebenfalls per LEFT JOIN
|
||||
* gefiltert, sodass in den Bereichen (Abteilung, Mandant, Rolle) entweder kein Eintrag vorliegt
|
||||
* oder der Eintrag mit den erlaubten Werten übereinstimmt.
|
||||
*/
|
||||
// Für Betriebsrat: nur Beiträge zeigen, die mindestens einer freigegebenen Kategorie zugeordnet sind
|
||||
$brCategoryFilter = '';
|
||||
if ($_kcBrOnly) {
|
||||
$brCatIds = get_all_betriebsrat_accessible_category_ids();
|
||||
if (empty($brCatIds)) {
|
||||
$brCategoryFilter = 'AND 1=0'; // kein Zugriff
|
||||
} else {
|
||||
$brCatIdList = implode(',', $brCatIds);
|
||||
$brCategoryFilter = "AND EXISTS (
|
||||
SELECT 1 FROM knowledgecenter_category_post kcp_br
|
||||
WHERE kcp_br.post_id = kp.id AND kcp_br.category_id IN ($brCatIdList)
|
||||
)";
|
||||
}
|
||||
}
|
||||
|
||||
$sqlPosts = "
|
||||
SELECT
|
||||
kp.id,
|
||||
kp.modified_at,
|
||||
pv.title,
|
||||
pv.image,
|
||||
pv.link,
|
||||
pv.link_label,
|
||||
pv.version_number
|
||||
FROM
|
||||
knowledgecenter_category_post AS kcp
|
||||
JOIN
|
||||
knowledgecenter_posts AS kp ON kcp.post_id = kp.id
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_versions AS pv ON kp.id = pv.post_id
|
||||
AND pv.language_id = $languageId
|
||||
AND pv.state = 1
|
||||
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
|
||||
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
|
||||
AND pv.version_number = (
|
||||
SELECT MAX(v2.version_number)
|
||||
FROM knowledgecenter_post_versions AS v2
|
||||
WHERE v2.post_id = kp.id
|
||||
AND v2.language_id = $languageId
|
||||
AND v2.state = 1
|
||||
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
|
||||
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
|
||||
)
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_department AS pd ON kp.id = pd.post_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_role AS pr ON kp.id = pr.post_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
|
||||
WHERE
|
||||
kcp.category_id = $categoryId
|
||||
AND pv.post_id IS NOT NULL
|
||||
AND ( pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments) )
|
||||
AND ( pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants) )
|
||||
AND ( pr.role_id IS NULL OR pr.role_id IN ($allowedRoles) )
|
||||
AND ( pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts) )
|
||||
AND ( pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche) )
|
||||
$brCategoryFilter
|
||||
GROUP BY
|
||||
kp.id
|
||||
ORDER BY
|
||||
$orderBy $orderDir
|
||||
";
|
||||
|
||||
$resPosts = mysqli_query($GLOBALS['mysql_con'], $sqlPosts);
|
||||
if ($resPosts && mysqli_num_rows($resPosts) > 0) {
|
||||
$fmt = new IntlDateFormatter('de_DE', IntlDateFormatter::LONG, IntlDateFormatter::SHORT);
|
||||
while ($post = mysqli_fetch_assoc($resPosts)) {
|
||||
$route = isset($_GET['route']) ? $_GET['route'] : '';
|
||||
$new_route = $route ? $route . ',' . $category['id'] : $category['id'];
|
||||
|
||||
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
$detailHref = "?action=Post&id={$post['id']}&language={$languageCode}&redirectURL={$redirectURL}&route={$new_route}";
|
||||
|
||||
$imgSrc = !empty($post['image']) ? '/userdata/' . htmlspecialchars($post['image']) : $placeholderPost;
|
||||
$titleText = htmlspecialchars($post['title'] ?: "Kein Titel vorhanden");
|
||||
|
||||
// Optionaler externer Link aus der Version
|
||||
$linkRaw = trim((string) ($post['link'] ?? ''));
|
||||
$linkLabelRaw = trim((string) ($post['link_label'] ?? ''));
|
||||
$linkHtml = '';
|
||||
if ($linkRaw !== '') {
|
||||
$hrefForCheck = $linkRaw;
|
||||
if (!preg_match('~^(?:https?:)?//|mailto:|/|#~i', $hrefForCheck)) {
|
||||
$hrefForCheck = 'https://' . $hrefForCheck;
|
||||
}
|
||||
$safeUrl = htmlspecialchars($hrefForCheck, ENT_QUOTES, 'UTF-8');
|
||||
$safeLabel = htmlspecialchars($linkLabelRaw !== '' ? $linkLabelRaw : $linkRaw, ENT_QUOTES, 'UTF-8');
|
||||
$linkHtml = " <a class='post_ext_link' href='" . $safeUrl . "' target='_blank' rel='noopener noreferrer'>" . $safeLabel . "</a>";
|
||||
}
|
||||
|
||||
echo "<div class='post_wiki'>";
|
||||
echo " <a class='post_thumb' href='" . $detailHref . "'>";
|
||||
echo " <img src='" . $imgSrc . "' alt='Beitrags Bild'>";
|
||||
echo " </a>";
|
||||
echo " <div class='card_header'>";
|
||||
echo " <a class='card_title' href='" . $detailHref . "'>" . $titleText . "</a>" . $linkHtml;
|
||||
echo " </div>";
|
||||
echo "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
echo "</div>"; // Ende des Grid-Containers für Detailansicht
|
||||
} else {
|
||||
echo "<div class='categories_grid'>";
|
||||
// Übersicht aller Top-Level Kategorien (nicht als Unterkategorie verlinkt)
|
||||
// mit LEFT JOIN zur Filterung anhand der User-Berechtigungen.
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id,
|
||||
t.title,
|
||||
t.description,
|
||||
t.image
|
||||
FROM
|
||||
knowledgecenter_categories_update AS c
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_department AS cd ON c.id = cd.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_role AS cr ON c.id = cr.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
|
||||
WHERE
|
||||
c.id NOT IN (SELECT child_category_id FROM knowledgecenter_category_links)
|
||||
AND c.state = 1
|
||||
AND ( cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments) )
|
||||
AND ( cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants) )
|
||||
AND ( cr.role_id IS NULL OR cr.role_id IN ($allowedRoles) )
|
||||
AND ( ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts) )
|
||||
AND ( cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche) )
|
||||
GROUP BY
|
||||
c.id
|
||||
ORDER BY
|
||||
c.sorting ASC
|
||||
";
|
||||
// var_dump($sql);
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], $sql);
|
||||
if ($res && mysqli_num_rows($res) > 0) {
|
||||
while ($row = mysqli_fetch_assoc($res)) {
|
||||
$route = isset($_GET['route']) ? $_GET['route'] : '';
|
||||
$new_route = $route ? $route . ',' . $row['id'] : $row['id'];
|
||||
|
||||
echo "<a class='post_wiki category_post' href=\"?detail={$row['id']}&route={$new_route}\">";
|
||||
echo "<div class='category'>";
|
||||
$imgSrc = !empty($row['image']) ? '/userdata/' . htmlspecialchars($row['image']) : $placeholderCategory;
|
||||
echo "<img src='" . $imgSrc . "' alt='Kategorie Bild'>";
|
||||
echo "<div class='card_title'>" . htmlspecialchars($row['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
|
||||
if (!empty($row['description'])) {
|
||||
echo "<div class='card_description'>" . htmlspecialchars($row['description']) . "</div>";
|
||||
}
|
||||
echo "</div>";
|
||||
echo "</a>";
|
||||
}
|
||||
}
|
||||
echo "</div>";
|
||||
}
|
||||
?>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Hilfsfunktion, um den Query-Parameter in einer URL zu aktualisieren
|
||||
function updateQueryStringParameter(uri, key, value) {
|
||||
var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
|
||||
var separator = uri.indexOf('?') !== -1 ? "&" : "?";
|
||||
if (uri.match(re)) {
|
||||
return uri.replace(re, '$1' + key + "=" + value + '$2');
|
||||
} else {
|
||||
return uri + separator + key + "=" + value;
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion zum Durchführen der Suche
|
||||
function performSearch(query, page = 1, append = false) {
|
||||
if (!append) {
|
||||
var newUrl = updateQueryStringParameter(window.location.href, 'query', encodeURIComponent(query));
|
||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||||
}
|
||||
$.ajax({
|
||||
url: '/module/knowledgecenter_update/Ajax/Ajax.php',
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'search_posts',
|
||||
query: query,
|
||||
language: '<?= $languageId ?>',
|
||||
main_contact_id: '<?= $GLOBALS["main_contact"]["id"] ?>',
|
||||
languageCode: '<?= $languageCode ?>',
|
||||
page: page
|
||||
},
|
||||
dataType: 'json',
|
||||
beforeSend: function () {
|
||||
$('#overlayLoader').show();
|
||||
},
|
||||
complete: function () {
|
||||
$('#overlayLoader').hide();
|
||||
},
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
if (append) {
|
||||
$('#searchResults').append(response.html);
|
||||
} else {
|
||||
$('#searchResults').html(response.html);
|
||||
}
|
||||
$('.categories_grid, .category_page_titlebar').hide();
|
||||
} else {
|
||||
$('#searchResults').html('<p>Fehler bei der Suche</p>');
|
||||
$('.categories_grid, .category_page_titlebar').show();
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$('#searchResults').html('<p>Verbindungsfehler</p>');
|
||||
// $('.categories_grid, .category_page_titlebar').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
// Setze den Seitentitel in <h1 id="intranetTitle"> basierend auf ?detail und vorhandenem Kategorienamen
|
||||
(function setIntranetTitle() {
|
||||
var h1 = document.getElementById('intranetTitle');
|
||||
console.log(h1);
|
||||
if (!h1) return;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var hasDetail = params.has('detail');
|
||||
var catTitle = (typeof window.kcCategoryTitle !== 'undefined' && window.kcCategoryTitle) ? String(window.kcCategoryTitle) : null;
|
||||
if (hasDetail && catTitle) {
|
||||
h1.textContent = catTitle;
|
||||
} else {
|
||||
h1.textContent = 'Wiki';
|
||||
}
|
||||
})();
|
||||
|
||||
const $searchInput = $('#searchInput');
|
||||
const $resultContainer = $('#searchResults');
|
||||
const $categoriesGrid = $('.categories_grid, .category_page_titlebar');
|
||||
|
||||
// Überprüfe, ob ein Suchparameter in der URL vorhanden ist:
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const queryParam = urlParams.get('query');
|
||||
if (queryParam && queryParam.length >= 2) {
|
||||
$searchInput.val(queryParam);
|
||||
performSearch(queryParam);
|
||||
}
|
||||
|
||||
let debounceTimer;
|
||||
$searchInput.on('input', function () {
|
||||
clearTimeout(debounceTimer);
|
||||
const query = $.trim($searchInput.val());
|
||||
if (query.length < 2) {
|
||||
$resultContainer.empty();
|
||||
$categoriesGrid.show();
|
||||
const newUrl = updateQueryStringParameter(window.location.href, 'query', '');
|
||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(function () {
|
||||
performSearch(query);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
$(document).on('click', '#loadMoreBtn', function () {
|
||||
var nextPage = $(this).data('page');
|
||||
var query = $.trim($searchInput.val());
|
||||
$(this).remove();
|
||||
performSearch(query, nextPage, true);
|
||||
});
|
||||
|
||||
// Definiere die beiden SVG-Icons als HTML-Strings
|
||||
const listIconHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-view-list" viewBox="0 0 16 16">
|
||||
<path d="M3 4.5h10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2m0 1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zM1 2a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 2m0 12a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 14"/>
|
||||
</svg>`;
|
||||
|
||||
const gridIconHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-grid" viewBox="0 0 16 16">
|
||||
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5z"/>
|
||||
</svg>`;
|
||||
|
||||
// Beim Laden der Seite prüfen, ob ein Modus im localStorage gespeichert wurde
|
||||
const savedMode = localStorage.getItem("categoryGridView");
|
||||
if (savedMode === "list") {
|
||||
$(".categories_grid").addClass("list_view");
|
||||
$("#changeCategoryGridView").addClass("active").html(listIconHTML);
|
||||
} else {
|
||||
$(".categories_grid").removeClass("list_view");
|
||||
$("#changeCategoryGridView").removeClass("active").html(gridIconHTML);
|
||||
}
|
||||
|
||||
// Klick-Event für den Toggle-Button
|
||||
$("#changeCategoryGridView").on("click", function () {
|
||||
$(".categories_grid").toggleClass("list_view");
|
||||
$(this).toggleClass("active");
|
||||
|
||||
// Je nach Zustand in localStorage speichern und das entsprechende Icon setzen:
|
||||
if ($(".categories_grid").hasClass("list_view")) {
|
||||
localStorage.setItem("categoryGridView", "list");
|
||||
$(this).html(listIconHTML);
|
||||
} else {
|
||||
localStorage.setItem("categoryGridView", "grid");
|
||||
$(this).html(gridIconHTML);
|
||||
}
|
||||
});
|
||||
|
||||
window.onpopstate = function (event) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get('query');
|
||||
if (q && q.length >= 2) {
|
||||
$searchInput.val(q);
|
||||
performSearch(q);
|
||||
} else {
|
||||
$searchInput.val('');
|
||||
$resultContainer.empty();
|
||||
$categoriesGrid.show();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
274
module/knowledgecenter_update/Views/category_posts_widget.php
Normal file
274
module/knowledgecenter_update/Views/category_posts_widget.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
require_once(__DIR__ . '/../Middleware/Middleware.php');
|
||||
|
||||
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
|
||||
$allowedMandants = $GLOBALS['allowedMandants'];
|
||||
$allowedDepartments = $GLOBALS['allowedDepartments'];
|
||||
$allowedRoles = $GLOBALS['allowedRoles'];
|
||||
|
||||
// Sprache ermitteln (Fallback: 1)
|
||||
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
|
||||
$languageCode = (string) ($GLOBALS['language']['code'] ?? 'de');
|
||||
$siteCode = (string) ($GLOBALS['site']['code'] ?? 'intranet');
|
||||
// Limit für Anzahl der Beiträge (fest): 100
|
||||
$limit = 100;
|
||||
|
||||
// Beiträge laden: jüngste Posts der letzten 3 Wochen, unabhängig von Kategorie.
|
||||
// Wir ziehen die aktuellste Version pro Sprache.
|
||||
$fallbackLanguageId = 1;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
kp.id,
|
||||
kp.modified_at,
|
||||
kp.created_at,
|
||||
pv.title,
|
||||
pv.image,
|
||||
pv.teaser,
|
||||
|
||||
-- Kategorien (Title + Color) in Wunsch-Sprache, sonst Fallback=1
|
||||
GROUP_CONCAT(
|
||||
DISTINCT CONCAT(
|
||||
COALESCE(kct_lang.title, kct_fb.title),
|
||||
'|',
|
||||
COALESCE(kcu.color_hex, '#3B82F6')
|
||||
)
|
||||
ORDER BY COALESCE(kct_lang.title, kct_fb.title)
|
||||
SEPARATOR '||'
|
||||
) AS category_tags
|
||||
|
||||
FROM knowledgecenter_posts AS kp
|
||||
|
||||
LEFT JOIN knowledgecenter_post_versions AS pv
|
||||
ON pv.post_id = kp.id
|
||||
AND pv.language_id = ?
|
||||
AND pv.state = 1
|
||||
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
|
||||
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
|
||||
AND pv.version_number = (
|
||||
SELECT MAX(v2.version_number)
|
||||
FROM knowledgecenter_post_versions v2
|
||||
WHERE v2.post_id = kp.id
|
||||
AND v2.language_id = ?
|
||||
AND v2.state = 1
|
||||
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
|
||||
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
|
||||
)
|
||||
|
||||
-- Kategorie-Verknüpfung Post -> Kategorie
|
||||
LEFT JOIN knowledgecenter_category_post AS kcp
|
||||
ON kcp.post_id = kp.id
|
||||
|
||||
-- Kategorie-Stammdaten (Farbe)
|
||||
LEFT JOIN knowledgecenter_categories_update AS kcu
|
||||
ON kcu.id = kcp.category_id
|
||||
|
||||
-- Kategorie Titel in Wunsch-Sprache
|
||||
LEFT JOIN knowledgecenter_category_translations AS kct_lang
|
||||
ON kct_lang.category_id = kcp.category_id
|
||||
AND kct_lang.language_id = ?
|
||||
|
||||
-- Fallback-Sprache (1)
|
||||
LEFT JOIN knowledgecenter_category_translations AS kct_fb
|
||||
ON kct_fb.category_id = kcp.category_id
|
||||
AND kct_fb.language_id = ?
|
||||
|
||||
-- Sichtbarkeiten (nur Einschränkungen existieren -> filtern, sonst sichtbar)
|
||||
LEFT JOIN knowledgecenter_post_department AS pd ON kp.id = pd.post_id
|
||||
LEFT JOIN knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
|
||||
LEFT JOIN knowledgecenter_post_role AS pr ON kp.id = pr.post_id
|
||||
LEFT JOIN knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
|
||||
LEFT JOIN knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
|
||||
|
||||
WHERE
|
||||
pv.post_id IS NOT NULL
|
||||
AND pv.modified_at >= DATE_SUB(NOW(), INTERVAL 3 WEEK)
|
||||
AND (pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments))
|
||||
AND (pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants))
|
||||
AND (pr.role_id IS NULL OR pr.role_id IN ($allowedRoles))
|
||||
AND (pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts))
|
||||
AND (pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche))
|
||||
|
||||
GROUP BY kp.id
|
||||
ORDER BY pv.modified_at DESC
|
||||
LIMIT ?
|
||||
";
|
||||
$stmt = mysqli_prepare($GLOBALS['mysql_con'], $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'iiiii', $languageId, $languageId, $languageId, $fallbackLanguageId, $limit);
|
||||
mysqli_stmt_execute($stmt);
|
||||
$res = mysqli_stmt_get_result($stmt);
|
||||
$posts = mysqli_fetch_all($res, MYSQLI_ASSOC);
|
||||
// if (!$posts) {
|
||||
// return;
|
||||
// }
|
||||
?>
|
||||
<style>
|
||||
.wiki_preview {
|
||||
float: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wiki_preview_grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
/* .wiki_preview_grid img {
|
||||
aspect-ratio: 4 / 3;
|
||||
border-bottom: 1px solid var(--border);
|
||||
} */
|
||||
|
||||
.wiki_preview_grid img {
|
||||
border-bottom: 1px solid var(--border);
|
||||
object-fit: contain;
|
||||
background: var(--bg-white);
|
||||
height: 186px ! Important;
|
||||
}
|
||||
|
||||
.card_title {
|
||||
padding: 1rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card_date {
|
||||
padding: 0 1rem;
|
||||
padding-top: 1rem;
|
||||
font-size: 12px;
|
||||
color: var(--font-light, #666);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.card_teaser {
|
||||
padding: 0 1rem;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 13px;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.wiki_preview_grid a {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--br);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
span.card_category {
|
||||
padding: 2px 7px;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
span.card_category_wrap {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.post_wiki:not(:has(.card_teaser)) {
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
</style>
|
||||
<div hidden>
|
||||
<?php
|
||||
// var_dump($GLOBALS['allowedMandants']);
|
||||
// var_dump($allowedDepartments);
|
||||
// var_dump($allowedRoles);
|
||||
?>
|
||||
</div>
|
||||
<div class="wiki_preview">
|
||||
<div class="wiki_preview_grid">
|
||||
<?php foreach ($posts as $p): ?>
|
||||
<?php
|
||||
$title = $p['title'] ?: ('Post #' . $p['id']);
|
||||
$imgSrc = !empty($p['image'])
|
||||
? '/userdata/' . htmlspecialchars($p['image'], ENT_QUOTES, 'UTF-8')
|
||||
: '/userdata/knowledgecenter_update/platzhalter.jpg';
|
||||
$teaser = isset($p['teaser']) ? trim((string) $p['teaser']) : '';
|
||||
$teaserEsc = htmlspecialchars($teaser, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Erstelldatum (Deutsch, z.B. 01.12.2025)
|
||||
$createdDateDisplay = '';
|
||||
if (!empty($p['created_at'])) {
|
||||
try {
|
||||
$createdDt = new DateTime($p['created_at']);
|
||||
$createdDateDisplay = $createdDt->format('d.m.Y');
|
||||
} catch (Exception $e) {
|
||||
$createdDateDisplay = '';
|
||||
}
|
||||
}
|
||||
$categoryTagsRaw = trim((string) ($p['category_tags'] ?? ''));
|
||||
$categoryTags = [];
|
||||
|
||||
if ($categoryTagsRaw !== '') {
|
||||
$items = explode('||', $categoryTagsRaw);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$parts = explode('|', $item, 2);
|
||||
$catTitle = trim($parts[0] ?? '');
|
||||
$catColor = trim($parts[1] ?? '');
|
||||
|
||||
if ($catTitle === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback Farbe falls leer / ungültig
|
||||
// if (!preg_match('/^#[0-9A-Fa-f]{8}$/', $catColor)) {
|
||||
// $catColor = '#3B82F6FF';
|
||||
// }
|
||||
|
||||
$categoryTags[] = [
|
||||
'title' => $catTitle,
|
||||
'color' => $catColor
|
||||
];
|
||||
}
|
||||
}
|
||||
// redirectURL = aktuelle Seite, damit man zurückkommt
|
||||
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
?>
|
||||
<a
|
||||
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= urlencode($_SERVER['REQUEST_URI']) ?>">
|
||||
<div class="post_wiki">
|
||||
<img src="<?= $imgSrc ?>" alt="Beitrags Bild">
|
||||
|
||||
<div class="card_date">
|
||||
<?= htmlspecialchars($createdDateDisplay, ENT_QUOTES, 'UTF-8') ?>
|
||||
|
||||
<?php if (!empty($categoryTags)): ?>
|
||||
<span class="card_category_wrap">
|
||||
<?php foreach ($categoryTags as $cat): ?>
|
||||
<?php
|
||||
$titleCat = (string) ($cat['title'] ?? '');
|
||||
$max = 15;
|
||||
|
||||
$short = (mb_strlen($titleCat, 'UTF-8') > $max)
|
||||
? mb_substr($titleCat, 0, $max - 1, 'UTF-8') . '…'
|
||||
: $titleCat;
|
||||
?>
|
||||
|
||||
<span class="card_category"
|
||||
style="background: <?= htmlspecialchars($cat['color'] ?? '', ENT_QUOTES, 'UTF-8') ?>;">
|
||||
<?= htmlspecialchars($short, ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card_title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php if ($teaser !== ''): ?>
|
||||
<div class="card_teaser"><?= $teaserEsc ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
/**
|
||||
* category_posts_widget.php
|
||||
*
|
||||
* Minimal-Widget: Zeigt alle Beiträge (Posts) einer hinterlegten Wiki-Kategorie an.
|
||||
* Erwartet: $sitepart['wiki_category_id'] ODER GET-Parameter `category_id`.
|
||||
* Sonst passiert nichts.
|
||||
*/
|
||||
|
||||
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
|
||||
require_once(__DIR__ . '/../Middleware/Middleware.php');
|
||||
|
||||
// Sichtbarkeitsfilter auf Basis der Benutzerberechtigungen (Mandant, Abteilung, Rolle)
|
||||
$allowedMandants = $GLOBALS['allowedMandants'];
|
||||
$allowedDepartments = $GLOBALS['allowedDepartments'];
|
||||
$allowedRoles = $GLOBALS['allowedRoles'];
|
||||
|
||||
// Sprache ermitteln (Fallback: 1)
|
||||
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
|
||||
$languageCode = (string) ($GLOBALS['language']['code'] ?? 'de');
|
||||
$siteCode = (string) ($GLOBALS['site']['code'] ?? 'intranet');
|
||||
// Kategorie-ID fest auf 49 gesetzt (statt aus Sitepart/GET)
|
||||
$categoryId = 49;
|
||||
// Beiträge der Kategorie laden (neueste zuerst). Wir ziehen die aktuellste Version pro Sprache.
|
||||
$sql = "
|
||||
SELECT
|
||||
kp.id,
|
||||
kp.modified_at,
|
||||
pv.title,
|
||||
pv.image,
|
||||
pv.teaser,
|
||||
pv.background_color AS version_background_color,
|
||||
pv.text_color AS version_text_color,
|
||||
kp.background_color AS post_background_color,
|
||||
kp.text_color AS post_text_color
|
||||
FROM knowledgecenter_category_post AS kcp
|
||||
JOIN knowledgecenter_posts AS kp
|
||||
ON kcp.post_id = kp.id
|
||||
LEFT JOIN knowledgecenter_post_versions AS pv
|
||||
ON pv.post_id = kp.id
|
||||
AND pv.language_id = ?
|
||||
AND pv.state = 1
|
||||
AND (pv.valid_from IS NULL OR pv.valid_from <= NOW())
|
||||
AND (pv.valid_until IS NULL OR pv.valid_until >= NOW())
|
||||
AND pv.version_number = (
|
||||
SELECT MAX(v2.version_number)
|
||||
FROM knowledgecenter_post_versions v2
|
||||
WHERE v2.post_id = kp.id
|
||||
AND v2.language_id = ?
|
||||
AND v2.state = 1
|
||||
AND (v2.valid_from IS NULL OR v2.valid_from <= NOW())
|
||||
AND (v2.valid_until IS NULL OR v2.valid_until >= NOW())
|
||||
)
|
||||
LEFT JOIN knowledgecenter_post_department AS pd ON kp.id = pd.post_id
|
||||
LEFT JOIN knowledgecenter_post_mandant AS pm ON kp.id = pm.post_id
|
||||
LEFT JOIN knowledgecenter_post_role AS pr ON kp.id = pr.post_id
|
||||
LEFT JOIN knowledgecenter_post_einricht AS pe ON kp.id = pe.post_id
|
||||
LEFT JOIN knowledgecenter_post_fachbereich AS pfb ON kp.id = pfb.post_id
|
||||
WHERE kcp.category_id = ?
|
||||
AND pv.post_id IS NOT NULL
|
||||
AND (pd.department_id IS NULL OR pd.department_id IN ($allowedDepartments))
|
||||
AND (pm.mandant_id IS NULL OR pm.mandant_id IN ($allowedMandants))
|
||||
AND (pr.role_id IS NULL OR pr.role_id IN ($allowedRoles))
|
||||
AND (pe.einricht_id IS NULL OR pe.einricht_id IN ($allowedEinrichts))
|
||||
AND (pfb.fachbereich_id IS NULL OR pfb.fachbereich_id IN ($allowedFachbereiche))
|
||||
GROUP BY kp.id
|
||||
ORDER BY kp.created_at DESC
|
||||
";
|
||||
$stmt = mysqli_prepare($GLOBALS['mysql_con'], $sql);
|
||||
mysqli_stmt_bind_param($stmt, 'iii', $languageId, $languageId, $categoryId);
|
||||
mysqli_stmt_execute($stmt);
|
||||
$res = mysqli_stmt_get_result($stmt);
|
||||
$posts = mysqli_fetch_all($res, MYSQLI_ASSOC);
|
||||
$postCount = is_array($posts) ? count($posts) : 0;
|
||||
$anncInstance = 'annc_' . uniqid();
|
||||
|
||||
$normalizeColor = function ($value) {
|
||||
$value = trim((string) $value);
|
||||
if (preg_match('/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/', $value)) {
|
||||
return strtoupper($value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
$resolvePostColor = function ($row, $versionKey, $postKey) use ($normalizeColor) {
|
||||
// Prioritaet: zuerst Post-Einstellung (Settings-Tab), dann Versionsfarbe.
|
||||
$postColor = $normalizeColor($row[$postKey] ?? '');
|
||||
if ($postColor !== '') {
|
||||
return $postColor;
|
||||
}
|
||||
|
||||
return $normalizeColor($row[$versionKey] ?? '');
|
||||
};
|
||||
|
||||
$sqlSubs = "
|
||||
SELECT
|
||||
c.id,
|
||||
t.title,
|
||||
t.image
|
||||
FROM
|
||||
knowledgecenter_category_links AS kcl
|
||||
JOIN
|
||||
knowledgecenter_categories_update AS c ON kcl.child_category_id = c.id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_translations AS t ON c.id = t.category_id AND t.language_id = $languageId
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_department AS cd ON c.id = cd.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_mandant AS cm ON c.id = cm.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_role AS cr ON c.id = cr.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_einricht AS ce ON c.id = ce.category_id
|
||||
LEFT JOIN
|
||||
knowledgecenter_category_fachbereich AS cf ON c.id = cf.category_id
|
||||
WHERE
|
||||
kcl.parent_category_id = $categoryId
|
||||
AND c.state = 1
|
||||
AND (cd.department_id IS NULL OR cd.department_id IN ($allowedDepartments))
|
||||
AND (cm.mandant_id IS NULL OR cm.mandant_id IN ($allowedMandants))
|
||||
AND (cr.role_id IS NULL OR cr.role_id IN ($allowedRoles))
|
||||
AND (ce.einricht_id IS NULL OR ce.einricht_id IN ($allowedEinrichts))
|
||||
AND (cf.fachbereich_id IS NULL OR cf.fachbereich_id IN ($allowedFachbereiche))
|
||||
GROUP BY
|
||||
c.id
|
||||
ORDER BY
|
||||
kcl.sorting ASC
|
||||
";
|
||||
|
||||
$placeholderCategory = '/userdata/intranet/mandant/platzhalter_kategorie.png';
|
||||
$resSubs = mysqli_query($GLOBALS['mysql_con'], $sqlSubs);
|
||||
$subCount = ($resSubs && mysqli_num_rows($resSubs) > 0) ? mysqli_num_rows($resSubs) : 0;
|
||||
|
||||
// Wenn weder Subkategorien noch Posts vorhanden sind, Widget gar nicht rendern
|
||||
if ($postCount === 0 && $subCount === 0) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<style>
|
||||
.announcement-list {
|
||||
color: white;
|
||||
float: left;
|
||||
width: 100%;
|
||||
background: var(--clr-mandant);
|
||||
border-radius: var(--br);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.announcement-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.card_title {
|
||||
padding: 1rem;
|
||||
padding-bottom: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.announcement-item {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.announcement {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.announcement-grid a {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--br);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.announcement-single {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.announcement-slider {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
--annc-progress: 0%;
|
||||
}
|
||||
|
||||
.announcement-slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: var(--annc-progress);
|
||||
background: white;
|
||||
transition: width 50ms linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.announcement-track {
|
||||
display: flex;
|
||||
transition: transform 0.3s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.announcement-slide {
|
||||
min-width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.announcement-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: rgb(255 255 255 / 30%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.announcement-nav.prev {
|
||||
left: .5rem;
|
||||
}
|
||||
|
||||
.announcement-nav.next {
|
||||
right: .5rem;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
<div class="announcement-list">
|
||||
<div class="announcement-grid">
|
||||
<?php
|
||||
if ($resSubs && $subCount > 0) {
|
||||
// Zeiger steht nach mysqli_num_rows() noch am Anfang; wir können direkt iterieren
|
||||
while ($sub = mysqli_fetch_assoc($resSubs)) {
|
||||
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
$url = "/" . htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') . "/" . htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') . "/wiki/?detail=" . $sub['id'] . "&redirectURL=" . $redirectURL;
|
||||
echo "<a href=\"{$url}\">";
|
||||
echo "<div class='subcategory'>";
|
||||
$imgSrc = !empty($sub['image']) ? '/userdata/' . htmlspecialchars($sub['image']) : $placeholderCategory;
|
||||
echo "<img src='" . $imgSrc . "' alt='Subkategorie Bild'>";
|
||||
echo "<div class='card_title'>" . htmlspecialchars($sub['title'] ?: "Keine Übersetzung vorhanden") . "</div>";
|
||||
echo "</div>";
|
||||
echo "</a>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php if ($postCount <= 1): ?>
|
||||
<div class="announcement-single" id="<?= $anncInstance ?>" tabindex="0">
|
||||
<?php foreach ($posts as $p): ?>
|
||||
<?php $title = $p['title'] ?: ('Post #' . $p['id']);
|
||||
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
$bgColor = $resolvePostColor($p, 'version_background_color', 'post_background_color');
|
||||
$textColor = $resolvePostColor($p, 'version_text_color', 'post_text_color');
|
||||
$styleParts = [];
|
||||
if ($bgColor !== '') {
|
||||
$styleParts[] = "background-color: {$bgColor}";
|
||||
}
|
||||
if ($textColor !== '') {
|
||||
$styleParts[] = "color: {$textColor}";
|
||||
}
|
||||
$announcementStyle = implode('; ', $styleParts);
|
||||
?>
|
||||
<a class="announcement"
|
||||
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= $redirectURL ?>">
|
||||
<div class="announcement-item" <?= $announcementStyle !== '' ? 'style="' . htmlspecialchars($announcementStyle, ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
|
||||
<div class="announcement-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="announcement-slider" id="<?= $anncInstance ?>" tabindex="0">
|
||||
<div class="announcement-track" data-track>
|
||||
<?php foreach ($posts as $p): ?>
|
||||
<?php $title = $p['title'] ?: ('Post #' . $p['id']);
|
||||
$redirectURL = urlencode($_SERVER['REQUEST_URI']);
|
||||
$bgColor = $resolvePostColor($p, 'version_background_color', 'post_background_color');
|
||||
$textColor = $resolvePostColor($p, 'version_text_color', 'post_text_color');
|
||||
$styleParts = [];
|
||||
if ($bgColor !== '') {
|
||||
$styleParts[] = "background-color: {$bgColor}";
|
||||
}
|
||||
if ($textColor !== '') {
|
||||
$styleParts[] = "color: {$textColor}";
|
||||
}
|
||||
$announcementStyle = implode('; ', $styleParts);
|
||||
?>
|
||||
<div class="announcement-slide" data-slide>
|
||||
<a class="announcement"
|
||||
href="/<?= htmlspecialchars($siteCode, ENT_QUOTES, 'UTF-8') ?>/<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>/wiki/?action=Post&id=<?= urlencode((string) $p['id']) ?>&language=<?= htmlspecialchars($languageCode, ENT_QUOTES, 'UTF-8') ?>&redirectURL=<?= $redirectURL ?>">
|
||||
<div class="announcement-item" <?= $announcementStyle !== '' ? 'style="' . htmlspecialchars($announcementStyle, ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
|
||||
<div class="announcement-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button class="announcement-nav prev" type="button" aria-label="Zurück" data-prev>‹</button>
|
||||
<button class="announcement-nav next" type="button" aria-label="Weiter" data-next>›</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.getElementById('<?= $anncInstance ?>');
|
||||
if (!root) return;
|
||||
var track = root.querySelector('[data-track]');
|
||||
var slides = Array.prototype.slice.call(root.querySelectorAll('[data-slide]'));
|
||||
var prevBtn = root.querySelector('[data-prev]');
|
||||
var nextBtn = root.querySelector('[data-next]');
|
||||
var idx = 0;
|
||||
|
||||
function update() {
|
||||
track.style.transform = 'translateX(' + (-idx * 100) + '%)';
|
||||
}
|
||||
|
||||
// --- Auto-Slide (500ms) + Progressbar ---
|
||||
var intervalMs = 10000; // gewünschtes Intervall
|
||||
var tickMs = 50; // Schrittweite für die Progressbar
|
||||
var progress = 0; // 0..100
|
||||
var timer = null;
|
||||
|
||||
function setProgress(pct) {
|
||||
root.style.setProperty('--annc-progress', Math.max(0, Math.min(100, pct)) + '%');
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
progress = 0;
|
||||
setProgress(0);
|
||||
}
|
||||
|
||||
function step() {
|
||||
progress += (tickMs / intervalMs) * 100; // z. B. 10% pro 50ms bei 500ms Gesamtzeit
|
||||
if (progress >= 100) {
|
||||
progress = 0;
|
||||
idx = (idx + 1) % slides.length;
|
||||
update();
|
||||
}
|
||||
setProgress(progress);
|
||||
}
|
||||
|
||||
function startAuto() {
|
||||
if (timer) return;
|
||||
timer = setInterval(step, tickMs);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (!timer) return;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
// Buttons
|
||||
if (prevBtn) prevBtn.addEventListener('click', function () {
|
||||
idx = (idx - 1 + slides.length) % slides.length;
|
||||
update();
|
||||
resetProgress();
|
||||
});
|
||||
if (nextBtn) nextBtn.addEventListener('click', function () {
|
||||
idx = (idx + 1) % slides.length;
|
||||
update();
|
||||
resetProgress();
|
||||
});
|
||||
|
||||
// Touch/swipe
|
||||
var startX = 0, deltaX = 0, dragging = false;
|
||||
track.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; dragging = true; }, { passive: true });
|
||||
track.addEventListener('touchmove', function (e) { if (!dragging) return; deltaX = e.touches[0].clientX - startX; }, { passive: true });
|
||||
track.addEventListener('touchend', function () {
|
||||
if (!dragging) return;
|
||||
if (Math.abs(deltaX) > 50) {
|
||||
if (deltaX < 0) { idx = (idx + 1) % slides.length; }
|
||||
else { idx = (idx - 1 + slides.length) % slides.length; }
|
||||
update();
|
||||
resetProgress();
|
||||
}
|
||||
deltaX = 0; dragging = false;
|
||||
});
|
||||
|
||||
// Keyboard
|
||||
root.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowRight') { idx = (idx + 1) % slides.length; update(); resetProgress(); }
|
||||
else if (e.key === 'ArrowLeft') { idx = (idx - 1 + slides.length) % slides.length; update(); resetProgress(); }
|
||||
});
|
||||
|
||||
// Optional: pausieren bei Hover/Focus für bessere Klickbarkeit
|
||||
root.addEventListener('mouseenter', stopAuto);
|
||||
root.addEventListener('mouseleave', function () { resetProgress(); startAuto(); });
|
||||
root.addEventListener('focusin', stopAuto);
|
||||
root.addEventListener('focusout', function () { resetProgress(); startAuto(); });
|
||||
|
||||
// Initial
|
||||
update();
|
||||
setProgress(0);
|
||||
if (slides.length > 1) startAuto();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
1045
module/knowledgecenter_update/Views/post_cardform.php
Normal file
1045
module/knowledgecenter_update/Views/post_cardform.php
Normal file
File diff suppressed because it is too large
Load Diff
318
module/knowledgecenter_update/Views/post_cardform_article.php
Normal file
318
module/knowledgecenter_update/Views/post_cardform_article.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
// if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['check_certificate_wiki'])) {
|
||||
// $certId = (int) $_POST['check_certificate_wiki'];
|
||||
// $userId = (int) $GLOBALS['main_contact']['id'];
|
||||
// $currentDate = (new DateTime())->format('Y-m-d H:i:s');
|
||||
|
||||
// // prüfen, ob schon ein Eintrag existiert
|
||||
// $sqlSubmit = "
|
||||
// SELECT id
|
||||
// FROM certificate_contact_submit
|
||||
// WHERE certificate_id = {$certId}
|
||||
// AND main_contact_id = {$userId}
|
||||
// LIMIT 1
|
||||
// ";
|
||||
// $resSubmit = mysqli_query($GLOBALS['mysql_con'], $sqlSubmit);
|
||||
|
||||
// if ($row = mysqli_fetch_assoc($resSubmit)) {
|
||||
// // UPDATE
|
||||
// $sqlUp = "
|
||||
// UPDATE certificate_contact_submit
|
||||
// SET correct = 1,
|
||||
// issue_date = '{$currentDate}'
|
||||
// WHERE id = " . (int) $row['id'];
|
||||
// mysqli_query($GLOBALS['mysql_con'], $sqlUp);
|
||||
// } else {
|
||||
// // INSERT
|
||||
// $sqlIn = "
|
||||
// INSERT INTO certificate_contact_submit
|
||||
// (certificate_id, main_contact_id, issue_date, correct)
|
||||
// VALUES
|
||||
// ({$certId}, {$userId}, '{$currentDate}', 1)
|
||||
// ";
|
||||
// mysqli_query($GLOBALS['mysql_con'], $sqlIn);
|
||||
// }
|
||||
|
||||
// echo "<script>
|
||||
// // ersetzt die aktuelle Seite durch einen sauberen GET auf dieselbe URL
|
||||
// window.location.replace(window.location.href);
|
||||
// </script>";
|
||||
// exit;
|
||||
// }
|
||||
// // fetch_and_process_emails();
|
||||
// $certificate_type = $sitepart['learning_certificate_type'];
|
||||
// $certificate_items = $sitepart['sitepart_items'];
|
||||
// if (isset($_GET['contact'])) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// $query = "
|
||||
// SELECT *
|
||||
// FROM main_certificate
|
||||
// LEFT JOIN certificate_contact
|
||||
// ON certificate_contact.certificate_id = main_certificate.id
|
||||
// WHERE main_contact_id = " . (int) $GLOBALS['main_contact']['id'] . "
|
||||
// AND main_certificate.state = 1
|
||||
// AND (main_certificate.validity_from <= NOW() OR main_certificate.validity_from IS NULL)
|
||||
// AND active = 1
|
||||
// ";
|
||||
// $result = @mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
|
||||
// $completedCertificates = [];
|
||||
// $incompleteCertificates = [];
|
||||
|
||||
// while ($row = @mysqli_fetch_assoc($result)) {
|
||||
// // statt $row['certificate_id'] immer main_certificate.id verwenden
|
||||
// $certId = (int) $row['id'];
|
||||
|
||||
// $query_submit = "
|
||||
// SELECT *
|
||||
// FROM certificate_contact_submit
|
||||
// WHERE certificate_id = {$certId}
|
||||
// AND main_contact_id = " . (int) $GLOBALS['main_contact']['id'] . "
|
||||
// ORDER BY id DESC
|
||||
// LIMIT 1
|
||||
// ";
|
||||
// $result_submit = @mysqli_query($GLOBALS['mysql_con'], $query_submit);
|
||||
// $row_submit = @mysqli_fetch_assoc($result_submit);
|
||||
|
||||
// $current_date = new DateTime();
|
||||
// $issue_date = new DateTime($row_submit['issue_date'] ?? 'now');
|
||||
// $validity_date = clone $issue_date;
|
||||
// $validity_date->modify('+' . (int) $row['validity_period'] . ' months');
|
||||
|
||||
// if (($row_submit['correct'] ?? 0) == 1 && $validity_date >= $current_date) {
|
||||
// $completedCertificates[] = [
|
||||
// 'row' => $row,
|
||||
// 'row_submit' => $row_submit,
|
||||
// 'issue_date' => $issue_date,
|
||||
// 'validity_date' => $validity_date
|
||||
// ];
|
||||
// } else {
|
||||
// $incompleteCertificates[] = [
|
||||
// 'row' => $row,
|
||||
// 'row_submit' => $row_submit,
|
||||
// 'issue_date' => $issue_date,
|
||||
// 'validity_date' => $validity_date
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
|
||||
// $siteParam = isset($_GET['site']) ? trim($_GET['site']) : '';
|
||||
// $langCode = $GLOBALS['language']['code'] ?? '';
|
||||
|
||||
// function buildCertificateLink(array $row, string $siteParam, string $langCode): string
|
||||
// {
|
||||
// if (!empty($row['main_collection_id'])) {
|
||||
// return get_link_to_navigation($row['main_collection_page_list_id'])
|
||||
// . get_collection_rewrite("-", $row['main_collection_id']);
|
||||
// }
|
||||
// if (!empty($row['post_id'])) {
|
||||
// return "/{$siteParam}/{$langCode}/wiki?action=Post&id=" . (int) $row['post_id'];
|
||||
// }
|
||||
// return "javascript:void(0)";
|
||||
// }
|
||||
?>
|
||||
|
||||
<div class="post-title">
|
||||
<?php if ($isNew): ?>
|
||||
<h1 id="displayTitle"><?= $translation->get("create_article") ?></h1>
|
||||
<?php else: ?>
|
||||
<h1 id="displayTitle"><?php echo htmlspecialchars($currentVersion['title']); ?></h1>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<?php if (!empty($postId) && $postId > 0 && ($canEditPost ?? false)): ?>
|
||||
<span id="editBtn" class="post-action-btn grey" title="Aktuelle Version bearbeiten">✎</span>
|
||||
<span id="createBtn" class="green" title="Neue Version erstellen">Neue Version erstellen +</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-grid-container">
|
||||
<div class="post-left-container">
|
||||
<?php if (!empty($currentVersion['teaser'])): ?>
|
||||
<div class="post-teaser" id="displayTeaser">
|
||||
<p><?php echo nl2br(htmlspecialchars($currentVersion['teaser'])); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
if (!empty($post['preview_inline']) && $post['preview_inline'] == 1) {
|
||||
$pdfQuery = "
|
||||
SELECT *
|
||||
FROM knowledgecenter_post_files
|
||||
WHERE post_id = " . intval($postId) . "
|
||||
AND LOWER(path) LIKE '%.pdf%'
|
||||
LIMIT 1
|
||||
";
|
||||
$pdfResult = mysqli_query($GLOBALS['mysql_con'], $pdfQuery);
|
||||
if ($pdfResult && mysqli_num_rows($pdfResult) > 0) {
|
||||
$pdfFile = mysqli_fetch_assoc($pdfResult);
|
||||
$pdfPath = htmlspecialchars($pdfFile['path']);
|
||||
?>
|
||||
<div class="post-preview-embed">
|
||||
<object data="<?php echo $pdfPath; ?>" type="application/pdf" width="100%" height="1000px">
|
||||
Ihr Browser unterstützt die Anzeige von PDFs nicht.
|
||||
<a href="<?php echo $pdfPath; ?>">PDF herunterladen</a>
|
||||
</object>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="post-content" id="displayContent">
|
||||
<?php echo $currentVersion['content']; ?>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div><!-- /.post-left-container -->
|
||||
|
||||
<div class="post-right-container">
|
||||
<?php if (!empty($postId) && $postId > 0): ?>
|
||||
<?php if (!empty($currentVersion['image'])): ?>
|
||||
<?php
|
||||
$imgSrc = '/userdata/' . htmlspecialchars($currentVersion['image']);
|
||||
?>
|
||||
<div class="post-aside-section">
|
||||
<a data-fslightbox="gallery" href="<?= $imgSrc ?>">
|
||||
<img id="displayImage" src="<?= $imgSrc ?>" alt="Beitragsbild">
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$linkRaw = trim((string) ($currentVersion['link'] ?? ''));
|
||||
$linkLabelRaw = trim((string) ($currentVersion['link_label'] ?? ''));
|
||||
if ($linkRaw !== '') {
|
||||
// Fallback Label: wenn kein Label angegeben, URL als Label nutzen
|
||||
$label = $linkLabelRaw !== '' ? $linkLabelRaw : $linkRaw;
|
||||
|
||||
// Falls es eine rohe Domain ohne Schema ist, https:// voranstellen
|
||||
$hrefForCheck = $linkRaw;
|
||||
if (!preg_match('~^(?:https?:)?//|mailto:|/|#~i', $hrefForCheck)) {
|
||||
$hrefForCheck = 'https://' . $hrefForCheck;
|
||||
}
|
||||
|
||||
$safeUrl = htmlspecialchars($hrefForCheck, ENT_QUOTES, 'UTF-8');
|
||||
$safeLabel = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<div class="post-aside-section">
|
||||
<h3><?= $translation->get("Link") ?></h3>
|
||||
<div class="post-link">
|
||||
<a href="<?= $safeUrl ?>" target="_blank" rel="noopener noreferrer"><?= $safeLabel ?></a>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php if (!empty($currentVersion['summary'])): ?>
|
||||
<div class="post-aside-section post-summary-section">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<div class="post-summary">
|
||||
<p><?= nl2br(htmlspecialchars($currentVersion['summary'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="filesContainer"></div>
|
||||
<div id="contactsContainer"></div>
|
||||
|
||||
<div class="post-aside-section">
|
||||
<h3><?= $translation->get("last_change_on") ?></h3>
|
||||
<div class="post-meta">
|
||||
<?php
|
||||
$timestamp = strtotime($currentVersion['modified_at']);
|
||||
$fmt = new IntlDateFormatter('de_DE', IntlDateFormatter::LONG, IntlDateFormatter::SHORT);
|
||||
$formattedDate = $fmt->format($timestamp);
|
||||
?>
|
||||
<div class="post-meta-item">
|
||||
<?= htmlspecialchars($formattedDate) ?> - <?= htmlspecialchars($currentVersion['author']) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- /.post-right-container -->
|
||||
</div><!-- /.post-grid-container -->
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
// ---- Kontakte laden ----
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "get_contact_cards",
|
||||
post_id: <?= $postId ?>
|
||||
}, "json")
|
||||
.done(function (response) {
|
||||
if (response.success && $.trim(response.html).length > 0) {
|
||||
var html = "<div class='post-aside-section'><h3><?= $translation->get("Ansprechpartner") ?></h3>";
|
||||
html += "<div id='contacts-content'>" + response.html + "</div></div>";
|
||||
$("#contactsContainer").html(html);
|
||||
} else {
|
||||
$("#contactsContainer").empty();
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
$("#contactsContainer").html("<p>Ein Fehler ist aufgetreten.</p>");
|
||||
});
|
||||
|
||||
// ---- Datei-Icons definieren ----
|
||||
var fileIcons = {
|
||||
pdf: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="red" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16"> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> <path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/> </svg>',
|
||||
image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="green" class="bi bi-file-earmark-image" viewBox="0 0 16 16"> <path d="M6.502 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> <path d="M14 14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zM4 1a1 1 0 0 0-1 1v10l2.224-2.224a.5.5 0 0 1 .61-.075L8 11l2.157-3.02a.5.5 0 0 1 .76-.063L13 10V4.5h-2A1.5 1.5 0 0 1 9.5 3V1z"/> </svg>',
|
||||
doc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="blue" class="bi bi-file-earmark-word" viewBox="0 0 16 16"> <path d="M5.485 6.879a.5.5 0 1 0-.97.242l1.5 6a.5.5 0 0 0 .967.01L8 9.402l1.018 3.73a.5.5 0 0 0 .967-.01l1.5-6a.5.5 0 0 0-.97-.242l-1.036 4.144-.997-3.655a.5.5 0 0 0-.964 0l-.997 3.655L5.485 6.88z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> </svg>',
|
||||
default: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text" viewBox="0 0 16 16"> <path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/> <path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/> </svg>'
|
||||
};
|
||||
|
||||
// ---- Dateien laden ----
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
data: {
|
||||
action: "get_post_files",
|
||||
post_id: <?= $postId ?>
|
||||
},
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.success && response.files && response.files.length > 0) {
|
||||
var html = "<div class='post-aside-section'><h3><?= $translation->get("files") ?></h3><div id='article-files-content'>";
|
||||
var imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
|
||||
var pdfExts = ['pdf'];
|
||||
var docExts = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||
|
||||
response.files.forEach(function (file, i) {
|
||||
var ext = file.path.split('.').pop().toLowerCase();
|
||||
if (!ext && file.description.indexOf('.') !== -1) {
|
||||
ext = file.description.split('.').pop().toLowerCase();
|
||||
}
|
||||
var icon = fileIcons.default;
|
||||
if (pdfExts.indexOf(ext) !== -1) icon = fileIcons.pdf;
|
||||
else if (imageExts.indexOf(ext) !== -1) icon = fileIcons.image;
|
||||
else if (docExts.indexOf(ext) !== -1) icon = fileIcons.doc;
|
||||
|
||||
html += "<div class='fileItem'>" + icon + " ";
|
||||
if (pdfExts.indexOf(ext) !== -1) {
|
||||
var embedId = "sidebar_pdf_" + i;
|
||||
html += "<a href='#" + embedId + "' data-fslightbox='sidebar_gallery' data-type='inline'>" + file.description + "</a>";
|
||||
html += "<object id='" + embedId + "' data='" + file.path + "' type='application/pdf' width='100%' height='900px'></object>";
|
||||
} else if (imageExts.indexOf(ext) !== -1) {
|
||||
html += "<a href='" + file.path + "' data-fslightbox='sidebar_gallery'>" + file.description + "</a>";
|
||||
} else {
|
||||
html += "<a href='" + file.path + "' download>" + file.description + "</a>";
|
||||
}
|
||||
html += "</div><hr>";
|
||||
});
|
||||
|
||||
html += "</div></div>";
|
||||
$("#filesContainer").html(html);
|
||||
refreshFsLightbox();
|
||||
} else {
|
||||
$("#filesContainer").empty();
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$("#filesContainer").html("<p>Ein Fehler ist aufgetreten.</p>");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
113
module/knowledgecenter_update/Views/post_cardform_contacts.php
Normal file
113
module/knowledgecenter_update/Views/post_cardform_contacts.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
// -------------------- Ansprechpartner für Post --------------------
|
||||
$postContactsData = [];
|
||||
$query = "SELECT id, name FROM main_contact ORDER BY name ASC";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$postContactsData[] = ['value' => $row['id'], 'name' => $row['name']];
|
||||
}
|
||||
|
||||
$selectedPostContacts = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT mc.id, mc.name
|
||||
FROM knowledgecenter_post_contacts AS pc
|
||||
JOIN main_contact AS mc ON pc.main_contact_id = mc.id
|
||||
WHERE pc.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostContacts[] = ['value' => $row['id'], 'name' => $row['name']];
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="titlebar_small">
|
||||
<h2><?=$translation->get("Ansprechpartner")?></h2>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||
<span data-toggle-target="contacts_form" class="toggle post-action-btn-small green ">+</span>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<hr>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||
<div data-toggle-target="contacts_form" class="toggle_container">
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postContactTags" name="postContacts" placeholder="<?=$translation->get("Ansprechpartner_hinzufuegen")?>" />
|
||||
<span id="saveContacts" title="Speichern" class="post-action-btn green"><?= $saveIcon ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div id="contactCards"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
var postContactsData = <?php echo json_encode($postContactsData); ?>;
|
||||
var selectedPostContacts = <?php echo json_encode($selectedPostContacts); ?>;
|
||||
|
||||
// Ansprechpartner mit Tagify initialisieren
|
||||
var tagifyPostContacts = new Tagify(document.getElementById('postContactTags'), {
|
||||
whitelist: postContactsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostContacts.length) {
|
||||
tagifyPostContacts.addTags(selectedPostContacts);
|
||||
}
|
||||
|
||||
// Speichern der Kontakte via AJAX (wie bereits implementiert)
|
||||
$("#saveContacts").on("click", function () {
|
||||
$("#tab-contacts").addClass("blurred");
|
||||
var selectedContacts = tagifyPostContacts.value.map(tag => tag.value);
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_contacts",
|
||||
post_id: <?= $postId ?>,
|
||||
contacts: JSON.stringify(selectedContacts)
|
||||
}, "json")
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
showMessage("Alle Daten erfolgreich gespeichert!", true);
|
||||
location.reload();
|
||||
} else {
|
||||
showMessage("Fehler beim Speichern: Ansprechpartner", false);
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
showMessage("Es ist ein Fehler beim Speichern aufgetreten!", false);
|
||||
})
|
||||
.always(function () {
|
||||
$("#tab-contacts").removeClass("blurred");
|
||||
});
|
||||
});
|
||||
|
||||
<?php if ($postId > 0): ?>
|
||||
// Direktes Laden und Anzeigen der Visitenkarten per AJAX beim Laden der Seite
|
||||
$(document).ready(function () {
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "get_contact_cards",
|
||||
post_id: <?= $postId ?>
|
||||
}, "json")
|
||||
.done(function (response) {
|
||||
if (response.success) {
|
||||
$("#contactCards").html(response.html);
|
||||
} else {
|
||||
$("#contactCards").html('<p>Fehler: ' + response.error + '</p>');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
$("#contactCards").html('<p>Ein Fehler ist aufgetreten.</p>');
|
||||
});
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
195
module/knowledgecenter_update/Views/post_cardform_discussion.php
Normal file
195
module/knowledgecenter_update/Views/post_cardform_discussion.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<div class="titlebar_small">
|
||||
<h2><?=$translation->get("Diskussion")?></h2>
|
||||
<span data-toggle-target="discussion_form" class="toggle post-action-btn-small green">+</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div data-toggle-target="discussion_form" class="toggle_container">
|
||||
<form id="newCommentForm">
|
||||
<input type="hidden" name="post_id" value="<?php echo $postId; ?>">
|
||||
<input type="hidden" name="author_id" value="<?php echo $GLOBALS['main_contact']['id']; ?>">
|
||||
<input type="text" class="input_round" name="text" placeholder="Text..." required>
|
||||
<button class="send_comment" title="Senden" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-send-fill"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<hr>
|
||||
</div>
|
||||
<div id="commentsContainer">
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
var currentUserId = <?php echo json_encode($GLOBALS['main_contact']['id']); ?>;
|
||||
|
||||
$(document).ready(function () {
|
||||
// Funktion zum Anzeigen des Antwortformulars
|
||||
$(document).on("click", ".replyCommentBtn", function () {
|
||||
var parentId = $(this).data("id");
|
||||
var $comment = $(this).closest('.comment');
|
||||
// Finde den entsprechenden Reply-Container
|
||||
var $replyContainer = $comment.find("div.replies[data-parent-id='" + parentId + "']");
|
||||
// Falls der Container nicht existiert, erstelle ihn
|
||||
if ($replyContainer.length === 0) {
|
||||
$comment.append("<div class='replies' data-parent-id='" + parentId + "'></div>");
|
||||
$replyContainer = $comment.find("div.replies[data-parent-id='" + parentId + "']");
|
||||
}
|
||||
// Prüfen, ob bereits ein Reply-Formular vorhanden ist
|
||||
if ($replyContainer.find(".replyCommentForm").length > 0) {
|
||||
// Eventuell: Formular toggle ausblenden/anzeigen oder
|
||||
// einfach nichts tun, um Mehrfach-Erzeugung zu vermeiden
|
||||
return; // Verhindert, dass ein weiteres Formular hinzugefügt wird
|
||||
}
|
||||
|
||||
// Erzeuge das Reply-Formular und füge es ein
|
||||
var replyFormHtml = "<form class='replyCommentForm'>" +
|
||||
"<input type='hidden' name='parent_id' value='" + parentId + "'>" +
|
||||
"<input type='hidden' name='post_id' value='<?php echo $postId; ?>'>" +
|
||||
"<input type='hidden' name='author_id' value='<?php echo $GLOBALS['main_contact']['id']; ?>'>" +
|
||||
"<input type='hidden' name='action' value='insert_comment'>" +
|
||||
"<input type='text' class='input_round' name='text' placeholder='<?=$translation->get("reply_placeholder")?>'>" +
|
||||
"<button class='send_comment' type='submit'>↪</button>" +
|
||||
"</form>";
|
||||
$replyContainer.append(replyFormHtml);
|
||||
});
|
||||
|
||||
|
||||
// Absenden des Antwortformulars
|
||||
$(document).on("submit", ".replyCommentForm", function (e) {
|
||||
e.preventDefault();
|
||||
var formData = $(this).serialize();
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", formData, function (response) {
|
||||
if (response.success) {
|
||||
// Kommentare neu laden – alternativ kann das neue Reply direkt angezeigt werden
|
||||
loadComments();
|
||||
} else {
|
||||
alert("Fehler: " + response.error);
|
||||
}
|
||||
}, "json");
|
||||
});
|
||||
|
||||
// Die bestehende loadComments()-Funktion anpassen:
|
||||
function loadComments() {
|
||||
// $("#overlayLoader").show(); // Zeige den Loader an
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: 'get_post_comments',
|
||||
post_id: <?php echo $postId; ?>
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
var html = '';
|
||||
if (response.comments.length === 0) {
|
||||
html = "<div class='no-comments'><?=$translation->get("no_comments")?></div>";
|
||||
} else {
|
||||
var commentMap = {};
|
||||
response.comments.forEach(function (comment) {
|
||||
commentMap[comment.id] = comment;
|
||||
comment.children = []; // initialisiere ein Array für Antworten
|
||||
});
|
||||
var roots = [];
|
||||
response.comments.forEach(function (comment) {
|
||||
if (comment.parent_id && commentMap[comment.parent_id]) {
|
||||
commentMap[comment.parent_id].children.push(comment);
|
||||
} else {
|
||||
roots.push(comment);
|
||||
}
|
||||
});
|
||||
function renderComment(comment, isLast) {
|
||||
var d = new Date(comment.created_at);
|
||||
var formattedDate = d.toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
|
||||
// Füge die Zusatzklasse hinzu, wenn isLast true ist
|
||||
var extraClass = isLast ? " last-comment" : "";
|
||||
var commentHtml = "<div class='comment" + extraClass + "' data-id='" + comment.id + "'>";
|
||||
commentHtml += " <div class='comment-content'>";
|
||||
commentHtml += " <div class='comment-header'>";
|
||||
commentHtml += " <strong>" + comment.author + "</strong> - <small>" + formattedDate + "</small>";
|
||||
commentHtml += " <p>" + comment.text + "</p>";
|
||||
commentHtml += " </div>";
|
||||
commentHtml += " <div class='comment-toolbar'>";
|
||||
commentHtml += " <span class='replyCommentBtn post-action-btn-small green' data-id='" + comment.id + "'>↪</span>";
|
||||
if (comment.author_id == currentUserId) {
|
||||
commentHtml += " <span class='deleteCommentBtn post-action-btn-small' data-id='" + comment.id + "' title='Kommentar löschen'>✕</span>";
|
||||
}
|
||||
commentHtml += " </div>";
|
||||
commentHtml += " </div>";
|
||||
|
||||
// Wenn Kinder vorhanden sind, durchlaufe diese
|
||||
if (comment.children && comment.children.length > 0) {
|
||||
commentHtml += " <div class='replies' data-parent-id='" + comment.id + "'>";
|
||||
comment.children.forEach(function (child, index, array) {
|
||||
// Bestimme, ob es das letzte Kind im Array ist
|
||||
var isLastChild = (index === array.length - 1);
|
||||
commentHtml += renderComment(child, isLastChild);
|
||||
});
|
||||
commentHtml += " </div>";
|
||||
}
|
||||
commentHtml += "</div>";
|
||||
return commentHtml;
|
||||
}
|
||||
|
||||
// Beim Rendern der Wurzeln rufst du renderComment() auf, hier kannst du z. B. false übergeben, da es beim Wurzelelement nicht relevant ist:
|
||||
roots.forEach(function (comment) {
|
||||
// Wenn du weißt, dass das Wurzelelement möglicherweise auch das letzte im Container ist,
|
||||
// kannst du den selben Check machen (z.B. isLast = index === roots.length-1)
|
||||
html += renderComment(comment, false);
|
||||
});
|
||||
|
||||
}
|
||||
$("#commentsContainer").html(html);
|
||||
}
|
||||
}, "json").always(function () {
|
||||
// $("#overlayLoader").hide(); // Blende den Loader aus, egal ob Erfolg oder Fehler
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Lade Kommentare beim Initialisieren
|
||||
loadComments();
|
||||
|
||||
// Bestehender Code für das neue Kommentarformular bleibt erhalten...
|
||||
$("#newCommentForm").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var $btn = $(this).find("button[type='submit']");
|
||||
$btn.prop("disabled", true);
|
||||
|
||||
var formData = $(this).serialize() + "&action=insert_comment";
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", formData, function (response) {
|
||||
if (response.success) {
|
||||
loadComments();
|
||||
$("#newCommentForm input[name='text']").val('');
|
||||
} else {
|
||||
alert("Fehler: " + response.error);
|
||||
}
|
||||
$btn.prop("disabled", false); // Reaktivieren nach Abschluss
|
||||
}, "json");
|
||||
});
|
||||
|
||||
|
||||
// Löschen eines Kommentars (bestehender Code) bleibt grundsätzlich gleich.
|
||||
$(document).on("click", ".deleteCommentBtn", function () {
|
||||
var commentId = $(this).data("id");
|
||||
if (confirm("Kommentar wirklich löschen?")) {
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", { action: 'delete_comment', comment_id: commentId, main_contact_id: <?php echo $GLOBALS["main_contact"]["id"]; ?> }, function (response) {
|
||||
if (response.success) {
|
||||
loadComments();
|
||||
} else {
|
||||
alert("Fehler: " + response.error);
|
||||
}
|
||||
}, "json");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
148
module/knowledgecenter_update/Views/post_cardform_edit_aside.php
Normal file
148
module/knowledgecenter_update/Views/post_cardform_edit_aside.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<h2>
|
||||
<?php
|
||||
// echo "<pre>";
|
||||
// var_dump($translations);
|
||||
// echo "</pre>";
|
||||
if (!$isNew) {
|
||||
?>
|
||||
<span id="cancelEditBtn" style="cursor:pointer;">←</span>
|
||||
<?php
|
||||
} else {
|
||||
?>
|
||||
<a href="javascript:history.back()">←</a>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
<span id="editAreaTitle"><?= $translation->get("edit_version") ?></span>
|
||||
</h2>
|
||||
|
||||
<!-- Language Tabs -->
|
||||
<div class="language-tabs tab-bar">
|
||||
<?php foreach ($allLanguages as $lang): ?>
|
||||
<div data-lang="<?= $lang['id'] ?>" class="<?= ($lang['id'] == $languageId) ? 'active' : '' ?>">
|
||||
<?= htmlspecialchars(strtoupper($lang['code'])) ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form id="editVersionForm">
|
||||
<!-- Gemeinsame Hidden-Felder -->
|
||||
<input type="hidden" name="post_id" value="<?= $postId; ?>">
|
||||
|
||||
<div class="edit-columns">
|
||||
<!-- Left column: Main content editing -->
|
||||
<div class="edit-main">
|
||||
<?php foreach ($allLanguages as $lang):
|
||||
$langId = $lang['id'];
|
||||
$trans = isset($translations[$langId]) ? $translations[$langId] : ['title' => '', 'image' => '', 'teaser' => '', 'content' => '', 'summary' => ''];
|
||||
?>
|
||||
<div class="lang-form" id="lang_<?= $langId ?>" data-lang="<?= $langId ?>"
|
||||
style="<?= ($langId == $languageId) ? '' : 'display: none;' ?>">
|
||||
<input type="hidden" name="version_id[<?= $langId ?>]" value="<?= $trans['id'] ?>">
|
||||
|
||||
<label for="title_<?= $langId ?>">Titel (<?= htmlspecialchars($lang['code']) ?>):</label>
|
||||
<input type="text" id="title_<?= $langId ?>" name="title[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($trans['title']) ?>">
|
||||
|
||||
<div class="single_image_group">
|
||||
<?php
|
||||
$imageIsValid = (!empty($trans['image']) && $trans['image'] !== 'null');
|
||||
$imagePath = $imageIsValid ? '/userdata/' . htmlspecialchars($trans['image']) : '/userdata/knowledgecenter/platzhalter.jpg';
|
||||
?>
|
||||
<img class="preview" id="preview-<?= $lang['code'] ?>" src="<?= $imagePath ?>" alt="Bildvorschau"
|
||||
style="max-width: 100px; min-height: 60px; border-radius: 5px; <?= $imageIsValid ? '' : 'background-color:#f3f3f3;'; ?>">
|
||||
|
||||
<input type="hidden" id="image_<?= $langId ?>" name="image[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($trans['image']) ?>">
|
||||
<div class="post-action-btn grey openFileGallery" data-target="image_<?= $langId ?>">+</div>
|
||||
<div class="post-action-btn grey clearImage" data-target="image_<?= $langId ?>">✕</div>
|
||||
</div>
|
||||
|
||||
<label for="teaser_<?= $langId ?>"><?= $translation->get("Teaser") ?></label>
|
||||
<textarea id="teaser_<?= $langId ?>"
|
||||
name="teaser[<?= $langId ?>]"><?= htmlspecialchars($trans['teaser']) ?></textarea>
|
||||
<hr>
|
||||
<div id="editor_<?= $langId ?>" class="editor">
|
||||
<?= $translations[$langId]['content'] ?>
|
||||
</div>
|
||||
<hr>
|
||||
<label for="summary_<?= $langId ?>">Zusammenfassung (Summary)</label>
|
||||
<textarea id="summary_<?= $langId ?>" name="summary[<?= $langId ?>]" rows="4"
|
||||
placeholder="Kurze Zusammenfassung des Artikelinhalts..."><?= htmlspecialchars($trans['summary'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Sidebar with save button and meta fields -->
|
||||
<div class="edit-sidebar">
|
||||
<?php if ($canEditPost ?? false) { ?>
|
||||
<span title="<?= $translation->get("save") ?>" id="saveVersionBtn" class="post-action-btn-text green">
|
||||
<?= $translation->get("save") ?>
|
||||
</span>
|
||||
<?php } ?>
|
||||
<hr>
|
||||
|
||||
<?php foreach ($allLanguages as $lang):
|
||||
$langId = $lang['id'];
|
||||
$trans = isset($translations[$langId]) ? $translations[$langId] : ['title' => '', 'image' => '', 'teaser' => '', 'content' => '', 'summary' => ''];
|
||||
?>
|
||||
<div class="lang-form-meta" data-lang="<?= $langId ?>"
|
||||
style="<?= ($langId == $languageId) ? '' : 'display: none;' ?>">
|
||||
<div class="post-edit-meta">
|
||||
<label for="link_<?= $langId ?>">Link
|
||||
<input type="url" id="link_<?= $langId ?>" name="link[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($trans['link'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
placeholder="https://...">
|
||||
</label>
|
||||
<label for="link_label_<?= $langId ?>">Link-Label
|
||||
<input type="text" id="link_label_<?= $langId ?>" name="link_label[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($trans['link_label'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
placeholder="z. B. Mehr erfahren">
|
||||
</label>
|
||||
<hr>
|
||||
<?php
|
||||
$fromVal = (
|
||||
!empty($trans['valid_from'])
|
||||
&& $trans['valid_from'] !== '0000-00-00 00:00:00'
|
||||
)
|
||||
? date('Y-m-d', strtotime($trans['valid_from']))
|
||||
: '';
|
||||
$untilVal = (
|
||||
!empty($trans['valid_until'])
|
||||
&& $trans['valid_until'] !== '0000-00-00 00:00:00'
|
||||
)
|
||||
? date('Y-m-d', strtotime($trans['valid_until']))
|
||||
: '';
|
||||
?>
|
||||
<label for="valid_from_<?= $langId ?>">
|
||||
Gültig ab:
|
||||
<input type="date" id="valid_from_<?= $langId ?>" name="valid_from[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($fromVal) ?>">
|
||||
</label>
|
||||
<label for="valid_until_<?= $langId ?>">
|
||||
Gültig bis:
|
||||
<input type="date" id="valid_until_<?= $langId ?>" name="valid_until[<?= $langId ?>]"
|
||||
value="<?= htmlspecialchars($untilVal) ?>">
|
||||
</label>
|
||||
<label for="state_<?= $langId ?>">Status:
|
||||
<select id="state_<?= $langId ?>" name="state[<?= $langId ?>]">
|
||||
<option value="1" <?= (isset($trans['state']) && $trans['state'] == 1) ? 'selected' : '' ?>>
|
||||
Aktiv
|
||||
</option>
|
||||
<option value="0" <?= (isset($trans['state']) && $trans['state'] == 0) ? 'selected' : '' ?>>
|
||||
Inaktiv
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="keep-modified-at-field">
|
||||
<label for="keep_modified_at" class="keep-modified-at-label"><?= $translation->get("keep_modified_at") ?></label>
|
||||
<input type="checkbox" id="keep_modified_at" name="keep_modified_at" value="1">
|
||||
</div>
|
||||
<input type="hidden" name="author_id[<?= $langId ?>]" value="<?php echo $GLOBALS['main_contact']['id']; ?>">
|
||||
<input type="hidden" name="content[<?= $langId ?>]" id="hiddenContent_<?= $langId ?>">
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
186
module/knowledgecenter_update/Views/post_cardform_files.php
Normal file
186
module/knowledgecenter_update/Views/post_cardform_files.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<div class="titlebar_small">
|
||||
<h2><?= $translation->get("files") ?></h2>
|
||||
<?php if (($canEditPost ?? false)) { ?>
|
||||
<div class="post-action-btn-small green openFileGalleryMulti" title="Datei hinzufügen"
|
||||
data-ajax-function="saveFilesAjax" data-target="post_files">+
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div class="post-action-btn green" id="saveFilesBtn" style="display:none;"><?= $saveIcon ?></div>
|
||||
</div>
|
||||
<hr>
|
||||
<form id="postSettingsForm">
|
||||
<input type="hidden" id="post_id" name="post_id" value="<?php echo $postId; ?>">
|
||||
<input type="hidden" id="post_files" name="post_files">
|
||||
</form>
|
||||
|
||||
<div id="filesDisplayContainer"></div>
|
||||
|
||||
<script>
|
||||
|
||||
var fileIcons = {
|
||||
pdf: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="red" class="bi bi-file-earmark-pdf" viewBox="0 0 16 16"> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> <path d="M4.603 14.087a.8.8 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.7 7.7 0 0 1 1.482-.645 20 20 0 0 0 1.062-2.227 7.3 7.3 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a11 11 0 0 0 .98 1.686 5.8 5.8 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.86.86 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.7 5.7 0 0 1-.911-.95 11.7 11.7 0 0 0-1.997.406 11.3 11.3 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.8.8 0 0 1-.58.029m1.379-1.901q-.25.115-.459.238c-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361q.016.032.026.044l.035-.012c.137-.056.355-.235.635-.572a8 8 0 0 0 .45-.606m1.64-1.33a13 13 0 0 1 1.01-.193 12 12 0 0 1-.51-.858 21 21 0 0 1-.5 1.05zm2.446.45q.226.245.435.41c.24.19.407.253.498.256a.1.1 0 0 0 .07-.015.3.3 0 0 0 .094-.125.44.44 0 0 0 .059-.2.1.1 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a4 4 0 0 0-.612-.053zM8.078 7.8a7 7 0 0 0 .2-.828q.046-.282.038-.465a.6.6 0 0 0-.032-.198.5.5 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822q.036.167.09.346z"/> </svg>',
|
||||
image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="green" class="bi bi-file-earmark-image" viewBox="0 0 16 16"> <path d="M6.502 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/> <path d="M14 14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zM4 1a1 1 0 0 0-1 1v10l2.224-2.224a.5.5 0 0 1 .61-.075L8 11l2.157-3.02a.5.5 0 0 1 .76-.063L13 10V4.5h-2A1.5 1.5 0 0 1 9.5 3V1z"/> </svg>',
|
||||
doc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="blue" class="bi bi-file-earmark-word" viewBox="0 0 16 16"> <path d="M5.485 6.879a.5.5 0 1 0-.97.242l1.5 6a.5.5 0 0 0 .967.01L8 9.402l1.018 3.73a.5.5 0 0 0 .967-.01l1.5-6a.5.5 0 0 0-.97-.242l-1.036 4.144-.997-3.655a.5.5 0 0 0-.964 0l-.997 3.655L5.485 6.88z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/> </svg>',
|
||||
default: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-text" viewBox="0 0 16 16"> <path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/> <path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/> </svg>'
|
||||
};
|
||||
|
||||
$("#saveFilesBtn").click(function () {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
data: {
|
||||
action: 'save_post_files',
|
||||
post_id: $('#post_id').val(),
|
||||
post_files: $('#post_files').val()
|
||||
},
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
// console.log("Dateien erfolgreich gespeichert.");
|
||||
location.reload(); // Seite neu laden, um die Änderungen anzuzeigen
|
||||
} else {
|
||||
console.error("Fehler beim Speichern der Dateien:", response.error);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error("AJAX Fehler:", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
window.saveFilesAjax = function () {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
data: {
|
||||
action: 'save_post_files',
|
||||
post_id: $('#post_id').val(),
|
||||
post_files: $('#post_files').val()
|
||||
},
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
console.log("Dateien erfolgreich gespeichert.");
|
||||
location.reload();
|
||||
} else {
|
||||
console.error("Fehler beim Speichern der Dateien:", response.error);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error("AJAX Fehler:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFileDisplayFromServer() {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
data: {
|
||||
action: 'get_post_files',
|
||||
post_id: $('#post_id').val()
|
||||
},
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
var html = "";
|
||||
// Definiere Listen für die verschiedenen Dateitypen
|
||||
var imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
|
||||
var pdfExtensions = ['pdf'];
|
||||
var downloadableExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
|
||||
|
||||
response.files.forEach(function (file, index) {
|
||||
// Ermitteln der Dateiendung (in Kleinbuchstaben)
|
||||
var fileExt = file.path.split('.').pop().toLowerCase();
|
||||
|
||||
// Falls keine Endung im Pfad enthalten ist, die Endung anhand der Beschreibung ermitteln:
|
||||
if (file.path.indexOf('.') === -1 && file.description.indexOf('.') !== -1) {
|
||||
fileExt = file.description.split('.').pop().toLowerCase();
|
||||
}
|
||||
index = index + 1; // Beginne bei 1
|
||||
|
||||
// Bestimme den passenden Icon-Code, basierend auf der Dateiendung
|
||||
var icon;
|
||||
if (fileExt === 'pdf') {
|
||||
icon = fileIcons.pdf;
|
||||
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].indexOf(fileExt) !== -1) {
|
||||
icon = fileIcons.image;
|
||||
} else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].indexOf(fileExt) !== -1) {
|
||||
icon = fileIcons.doc;
|
||||
} else {
|
||||
icon = fileIcons.default;
|
||||
}
|
||||
|
||||
// Definiere den Lösch-Button
|
||||
<?php if (($canEditPost ?? false)) { ?>
|
||||
var deleteButton = "<span class='file-delete post-action-btn-small' data-file-id='" + file.id + "' onclick='deleteFile(" + file.id + ")'>✕</span>";
|
||||
<?php } else {
|
||||
?>
|
||||
var deleteButton = "";
|
||||
<?php
|
||||
} ?>
|
||||
// Erzeugen des HTML abhängig vom Dateityp
|
||||
var htmlSegment = "<div class='fileItem'>" + icon + " ";
|
||||
|
||||
if (fileExt === 'pdf') {
|
||||
var embedId = "embed_" + index;
|
||||
htmlSegment += "<a href='#" + embedId + "' data-fslightbox='gallery' data-type='inline'>" + file.description + "</a>";
|
||||
htmlSegment += deleteButton + "</div>";
|
||||
htmlSegment += "<object id='" + embedId + "' data='" + file.path + "' type='application/pdf' width='100%' height='900px'></object>";
|
||||
}
|
||||
else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].indexOf(fileExt) !== -1) {
|
||||
htmlSegment += "<a href='" + file.path + "' data-fslightbox='gallery'>" + file.description + "</a>" + deleteButton + "</div>";
|
||||
}
|
||||
else if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].indexOf(fileExt) !== -1) {
|
||||
htmlSegment += "<a href='" + file.path + "' download>" + file.description + "</a>" + deleteButton + "</div>";
|
||||
}
|
||||
else {
|
||||
htmlSegment += "<a href='" + file.path + "' download>" + file.description + "</a>" + deleteButton + "</div>";
|
||||
}
|
||||
|
||||
htmlSegment += "<hr>";
|
||||
html += htmlSegment;
|
||||
});
|
||||
|
||||
|
||||
$("#filesDisplayContainer").html(html);
|
||||
|
||||
refreshFsLightbox();
|
||||
|
||||
} else {
|
||||
console.error("Fehler beim Abrufen der Dateien:", response.error);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error("AJAX Fehler beim Abrufen der Dateien:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFile(fileId) {
|
||||
if (confirm("Möchten Sie diese Datei wirklich löschen?")) {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/module/knowledgecenter_update/Ajax/Ajax.php",
|
||||
data: {
|
||||
action: 'delete_post_file',
|
||||
file_id: fileId
|
||||
},
|
||||
dataType: "json",
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
updateFileDisplayFromServer();
|
||||
} else {
|
||||
console.error("Fehler beim Löschen der Datei:", response.error);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error("AJAX Fehler:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Dateien direkt beim Laden der Seite abrufen
|
||||
updateFileDisplayFromServer();
|
||||
</script>
|
||||
577
module/knowledgecenter_update/Views/post_cardform_settings.php
Normal file
577
module/knowledgecenter_update/Views/post_cardform_settings.php
Normal file
@@ -0,0 +1,577 @@
|
||||
<?php
|
||||
// post_cardform_settings.php
|
||||
|
||||
// Sicherstellen, dass $postId gesetzt ist – andernfalls neuen Beitrag (0) annehmen
|
||||
if (!isset($postId)) {
|
||||
$postId = 0;
|
||||
}
|
||||
|
||||
// Falls $languageId noch nicht definiert ist, Standard (z. B. 1) verwenden
|
||||
if (!isset($languageId)) {
|
||||
$languageId = 1;
|
||||
}
|
||||
|
||||
// Load current linked form for this post (if any)
|
||||
$currentFormId = 0;
|
||||
$res = mysqli_query($GLOBALS['mysql_con'], "
|
||||
SELECT form_id
|
||||
FROM knowledgecenter_post_form
|
||||
WHERE post_id = $postId
|
||||
LIMIT 1
|
||||
");
|
||||
if ($row = mysqli_fetch_assoc($res)) {
|
||||
$currentFormId = (int)$row['form_id'];
|
||||
}
|
||||
|
||||
// Build <option> list from contactform_header with 'selected' on current form
|
||||
$formOptions = "";
|
||||
$query = "SELECT id, description FROM contactform_header ORDER BY id ASC";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
// Mark the currently linked form as selected
|
||||
$selected = ($row['id'] == $currentFormId) ? 'selected' : '';
|
||||
$formOptions .= "<option value='" . $row['id'] . "' $selected>"
|
||||
. htmlspecialchars($row['description']) . "</option>";
|
||||
}
|
||||
|
||||
// -------------------- Kategorien --------------------
|
||||
// Erzeuge die Whitelist für alle verfügbaren Kategorien (mit Übersetzung)
|
||||
// Betriebsrat sieht nur Kategorien, die für ihn freigegeben sind (direkt oder über Elternkategorie)
|
||||
$categoriesData = [];
|
||||
$query = "SELECT c.id, t.title, c.enable_post_color_pickers
|
||||
FROM knowledgecenter_categories_update AS c
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON c.id = t.category_id
|
||||
WHERE t.language_id = $languageId";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
$isBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
if ($isBrOnly && !is_category_betriebsrat_editable((int)$row['id'])) {
|
||||
continue;
|
||||
}
|
||||
$categoriesData[] = [
|
||||
'value' => $row['id'],
|
||||
'name' => $row['title'],
|
||||
'enable_post_color_pickers' => (int) ($row['enable_post_color_pickers'] ?? 0)
|
||||
];
|
||||
}
|
||||
|
||||
// Bereits zugeordnete Kategorien abrufen (falls vorhanden)
|
||||
$selectedCategories = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT c.id, t.title
|
||||
FROM knowledgecenter_category_post AS pc
|
||||
JOIN knowledgecenter_categories_update AS c ON pc.category_id = c.id
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON c.id = t.category_id
|
||||
WHERE pc.post_id = $postId AND t.language_id = $languageId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedCategories[] = ['value' => $row['id'], 'name' => $row['title']];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Mandanten für Post --------------------
|
||||
$postMandantsData = [];
|
||||
$query = "SELECT id, description FROM main_mandant";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
// Hier verwenden wir 'name' als Anzeige-Text, der die description enthält.
|
||||
$postMandantsData[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
$selectedPostMandants = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT m.id, m.description
|
||||
FROM knowledgecenter_post_mandant AS pm
|
||||
JOIN main_mandant AS m ON pm.mandant_id = m.id
|
||||
WHERE pm.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostMandants[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Departments für Post --------------------
|
||||
$postDepartmentsData = [];
|
||||
$query = "SELECT id, description FROM main_department";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$postDepartmentsData[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
$selectedPostDepartments = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT d.id, d.description
|
||||
FROM knowledgecenter_post_department AS pd
|
||||
JOIN main_department AS d ON pd.department_id = d.id
|
||||
WHERE pd.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostDepartments[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Rollen für Post --------------------
|
||||
$postRolesData = [];
|
||||
$query = "SELECT id, description FROM main_role";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$postRolesData[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
$selectedPostRoles = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT r.id, r.description
|
||||
FROM knowledgecenter_post_role AS pr
|
||||
JOIN main_role AS r ON pr.role_id = r.id
|
||||
WHERE pr.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostRoles[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Einrichtungen für Post --------------------
|
||||
$postEinrichtsData = [];
|
||||
$query = "SELECT id, description FROM organigramm_einricht ORDER BY description ASC";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$postEinrichtsData[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
|
||||
// Verknüpfungskarte für Einrichtungen (Gruppen → Mitglieder)
|
||||
$postEinrichtLinks = [];
|
||||
$linkRes = mysqli_query($GLOBALS['mysql_con'], "SELECT einricht_id, linked_einricht_id FROM organigramm_einricht_link");
|
||||
while ($lr = mysqli_fetch_assoc($linkRes)) {
|
||||
$postEinrichtLinks[strval($lr['einricht_id'])][] = strval($lr['linked_einricht_id']);
|
||||
}
|
||||
$selectedPostEinrichts = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT e.id, e.description
|
||||
FROM knowledgecenter_post_einricht AS pe
|
||||
JOIN organigramm_einricht AS e ON pe.einricht_id = e.id
|
||||
WHERE pe.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostEinrichts[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- Fachbereiche für Post --------------------
|
||||
$postFachbereicheData = [];
|
||||
$query = "SELECT id, description FROM organogramm_space ORDER BY description ASC";
|
||||
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||
while ($row = mysqli_fetch_assoc($result)) {
|
||||
$postFachbereicheData[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
$selectedPostFachbereiche = [];
|
||||
if ($postId != 0) {
|
||||
$querySelected = "SELECT s.id, s.description
|
||||
FROM knowledgecenter_post_fachbereich AS pfb
|
||||
JOIN organogramm_space AS s ON pfb.fachbereich_id = s.id
|
||||
WHERE pfb.post_id = $postId";
|
||||
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||
$selectedPostFachbereiche[] = ['value' => $row['id'], 'name' => $row['description']];
|
||||
}
|
||||
}
|
||||
|
||||
$postBackgroundColor = '#FFFFFF';
|
||||
if (!empty($post['background_color'])) {
|
||||
$postBackgroundColor = $post['background_color'];
|
||||
} elseif (!empty($currentVersion['background_color'])) {
|
||||
$postBackgroundColor = $currentVersion['background_color'];
|
||||
}
|
||||
|
||||
$postTextColor = '#000000';
|
||||
if (!empty($post['text_color'])) {
|
||||
$postTextColor = $post['text_color'];
|
||||
} elseif (!empty($currentVersion['text_color'])) {
|
||||
$postTextColor = $currentVersion['text_color'];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
?>
|
||||
<button id="message" style="opacity:0; position: absolute;"></button>
|
||||
<div class="titlebar_small">
|
||||
<h2><?=$translation->get("settings")?></h2>
|
||||
<?php if ($canEditPost ?? false) { ?>
|
||||
<span id="saveAllBtn" class="post-action-btn-small green"><?= $saveIcon ?></span>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="settings">
|
||||
<form id="postSettingsForm">
|
||||
<input type="hidden" id="post_id" name="post_id" value="<?php echo $postId; ?>" />
|
||||
|
||||
<h3><?=$translation->get("categories")?>
|
||||
</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="categoryTags" name="categories" placeholder="Kategorie hinzufügen…" />
|
||||
</div>
|
||||
|
||||
<div id="postColorPickersSection" class="post-color-grid" style="display:none;">
|
||||
<div class="post-color-item">
|
||||
<label for="postBackgroundColor"><?= $translation->get("post_background_color") ?></label>
|
||||
<input type="color" id="postBackgroundColor" name="background_color"
|
||||
value="<?= htmlspecialchars($postBackgroundColor, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
|
||||
<div class="post-color-item">
|
||||
<label for="postTextColor"><?= $translation->get("post_text_color") ?></label>
|
||||
<input type="color" id="postTextColor" name="text_color"
|
||||
value="<?= htmlspecialchars($postTextColor, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h3><?=$translation->get("Geschäftsstellen")?>
|
||||
</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postMandantTags" name="postMandants" placeholder="<?=$translation->get("Geschäftsstellen")?>…" />
|
||||
</div>
|
||||
|
||||
<h3><?=$translation->get("departments")?>
|
||||
</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postDepartmentTags" name="postDepartments" placeholder="<?=$translation->get("departments")?>…" />
|
||||
</div>
|
||||
|
||||
<h3><?=$translation->get("roles")?>
|
||||
</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postRoleTags" name="postRoles" placeholder="<?=$translation->get("roles")?>…" />
|
||||
</div>
|
||||
|
||||
<h3>Einrichtungen</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postEinrichtTags" name="postEinrichts" placeholder="Einrichtung hinzufügen…" />
|
||||
</div>
|
||||
|
||||
<h3>Fachbereiche</h3>
|
||||
<div class="settings_container">
|
||||
<input type="text" id="postFachbereichTags" name="postFachbereiche" placeholder="Fachbereich hinzufügen…" />
|
||||
</div>
|
||||
|
||||
<h3>Formular auswählen</h3>
|
||||
<div class="settings_container">
|
||||
<select name="postFormSelect" id="postFormSelect">
|
||||
<option value="">Bitte wählen…</option>
|
||||
<?php echo $formOptions; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hgroup>
|
||||
<h3><?=$translation->get("pdf_inline_preview")?></h3>
|
||||
<small><?=$translation->get("pdf_inline_preview_explanation")?></small>
|
||||
</hgroup>
|
||||
<div class="settings_container">
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="pdfInlineCheckbox" name="pdf_inline" value="1"
|
||||
<?php echo (!empty($post['preview_inline']) && $post['preview_inline'] == 1) ? 'checked' : ''; ?>>
|
||||
<?=$translation->get("pdf_inline_preview_label")?>
|
||||
</label>
|
||||
</div>
|
||||
<h3><?=$translation->get("delete_post")?></h3>
|
||||
<div class="settings_container">
|
||||
<div id="deletePostBtn" class="post-action-btn-text red">
|
||||
<?=$translation->get("delete_post")?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// Übergabe der Whitelist-Daten aus PHP
|
||||
var categoriesData = <?php echo json_encode($categoriesData); ?>;
|
||||
var postMandantsData = <?php echo json_encode($postMandantsData); ?>;
|
||||
var postDepartmentsData = <?php echo json_encode($postDepartmentsData); ?>;
|
||||
var postRolesData = <?php echo json_encode($postRolesData); ?>;
|
||||
var postEinrichtsData = <?php echo json_encode($postEinrichtsData); ?>;
|
||||
var postFachbereicheData = <?php echo json_encode($postFachbereicheData); ?>;
|
||||
var selectedCategories = <?php echo json_encode($selectedCategories); ?>;
|
||||
var selectedPostMandants = <?php echo json_encode($selectedPostMandants); ?>;
|
||||
var selectedPostDepartments = <?php echo json_encode($selectedPostDepartments); ?>;
|
||||
var selectedPostRoles = <?php echo json_encode($selectedPostRoles); ?>;
|
||||
var selectedPostEinrichts = <?php echo json_encode($selectedPostEinrichts); ?>;
|
||||
var selectedPostFachbereiche = <?php echo json_encode($selectedPostFachbereiche); ?>;
|
||||
var categoryEnableColorMap = {};
|
||||
categoriesData.forEach(function (cat) {
|
||||
categoryEnableColorMap[String(cat.value)] = parseInt(cat.enable_post_color_pickers || 0, 10) === 1;
|
||||
});
|
||||
|
||||
function updatePostColorPickerVisibility() {
|
||||
var selectedValues = tagifyCategories.value.map(function (tag) {
|
||||
return String(tag.value);
|
||||
});
|
||||
|
||||
var hasEnabledCategory = selectedValues.some(function (catId) {
|
||||
return categoryEnableColorMap[catId] === true;
|
||||
});
|
||||
|
||||
if (hasEnabledCategory) {
|
||||
$("#postColorPickersSection").css("display", "grid");
|
||||
} else {
|
||||
$("#postColorPickersSection").hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Kategorien
|
||||
var tagifyCategories = new Tagify(document.getElementById('categoryTags'), {
|
||||
whitelist: categoriesData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: {
|
||||
enabled: 0,
|
||||
maxItems: Infinity,
|
||||
searchKeys: ["name"]
|
||||
},
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedCategories.length) {
|
||||
tagifyCategories.addTags(selectedCategories);
|
||||
}
|
||||
|
||||
tagifyCategories.on('add', updatePostColorPickerVisibility);
|
||||
tagifyCategories.on('remove', updatePostColorPickerVisibility);
|
||||
updatePostColorPickerVisibility();
|
||||
|
||||
// Mandanten für Post
|
||||
var tagifyPostMandants = new Tagify(document.getElementById('postMandantTags'), {
|
||||
whitelist: postMandantsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostMandants.length) {
|
||||
tagifyPostMandants.addTags(selectedPostMandants);
|
||||
}
|
||||
|
||||
// Departments für Post
|
||||
var tagifyPostDepartments = new Tagify(document.getElementById('postDepartmentTags'), {
|
||||
whitelist: postDepartmentsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostDepartments.length) {
|
||||
tagifyPostDepartments.addTags(selectedPostDepartments);
|
||||
}
|
||||
|
||||
// Rollen für Post
|
||||
var tagifyPostRoles = new Tagify(document.getElementById('postRoleTags'), {
|
||||
whitelist: postRolesData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostRoles.length) {
|
||||
tagifyPostRoles.addTags(selectedPostRoles);
|
||||
}
|
||||
|
||||
// Einrichtungen für Post
|
||||
var tagifyPostEinrichts = new Tagify(document.getElementById('postEinrichtTags'), {
|
||||
whitelist: postEinrichtsData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostEinrichts.length) {
|
||||
tagifyPostEinrichts.addTags(selectedPostEinrichts);
|
||||
}
|
||||
var postEinrichtLinks = <?= json_encode($postEinrichtLinks) ?>;
|
||||
tagifyPostEinrichts.on('add', function(e) {
|
||||
var addedId = String(e.detail.data.value);
|
||||
(postEinrichtLinks[addedId] || []).forEach(function(lid) {
|
||||
var exists = tagifyPostEinrichts.value.some(function(t) { return String(t.value) === lid; });
|
||||
if (!exists) {
|
||||
var item = postEinrichtsData.find(function(d) { return String(d.value) === lid; });
|
||||
if (item) tagifyPostEinrichts.addTags([item]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fachbereiche für Post
|
||||
var tagifyPostFachbereiche = new Tagify(document.getElementById('postFachbereichTags'), {
|
||||
whitelist: postFachbereicheData,
|
||||
tagTextProp: 'name',
|
||||
enforceWhitelist: true,
|
||||
dropdown: { enabled: 0, maxItems: Infinity, searchKeys: ["name"] },
|
||||
templates: {
|
||||
dropdownItem: function (tagData) {
|
||||
return `<div ${this.getAttributes(tagData)} class="tagify__dropdown__item">
|
||||
<span>${tagData.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selectedPostFachbereiche.length) {
|
||||
tagifyPostFachbereiche.addTags(selectedPostFachbereiche);
|
||||
}
|
||||
|
||||
<?php if ($canEditPost ?? false) { ?>
|
||||
$("#saveAllBtn").on("click", function () {
|
||||
// Während des Speicherns blurren
|
||||
$("#tab-settings").addClass("blurred");
|
||||
|
||||
var postId = $("#post_id").val();
|
||||
var pdfInline = $("#pdfInlineCheckbox").prop("checked") ? 1 : 0;
|
||||
var selectedCat = tagifyCategories.value.map(tag => tag.value);
|
||||
var selectedMandants = tagifyPostMandants.value.map(tag => tag.value);
|
||||
var selectedDepartments = tagifyPostDepartments.value.map(tag => tag.value);
|
||||
var selectedRoles = tagifyPostRoles.value.map(tag => tag.value);
|
||||
var selectedEinrichts = tagifyPostEinrichts.value.map(tag => tag.value);
|
||||
var selectedFachbereiche = tagifyPostFachbereiche.value.map(tag => tag.value);
|
||||
var selectedFormId = $("#postFormSelect").val();
|
||||
var postBackgroundColor = $("#postBackgroundColor").val();
|
||||
var postTextColor = $("#postTextColor").val();
|
||||
|
||||
|
||||
$.when(
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_categories",
|
||||
post_id: postId,
|
||||
categories: JSON.stringify(selectedCat)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_mandants",
|
||||
post_id: postId,
|
||||
mandants: JSON.stringify(selectedMandants)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_form",
|
||||
post_id: postId,
|
||||
form_id: selectedFormId
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_departments",
|
||||
post_id: postId,
|
||||
departments: JSON.stringify(selectedDepartments)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_roles",
|
||||
post_id: postId,
|
||||
roles: JSON.stringify(selectedRoles)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_einrichts",
|
||||
post_id: postId,
|
||||
einrichts: JSON.stringify(selectedEinrichts)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_fachbereiche",
|
||||
post_id: postId,
|
||||
fachbereiche: JSON.stringify(selectedFachbereiche)
|
||||
}, "json"),
|
||||
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_pdf_inline",
|
||||
post_id: postId,
|
||||
pdf_inline: pdfInline
|
||||
}, "json"),
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "update_post_colors",
|
||||
post_id: postId,
|
||||
background_color: postBackgroundColor,
|
||||
text_color: postTextColor
|
||||
}, "json")
|
||||
).done(function (categoriesResponse, mandantsResponse, formResponse, departmentsResponse, rolesResponse, einrichtsResponse, fachbereicheResponse, pdfInlineResponse, postColorsResponse) {
|
||||
var catSuccess = categoriesResponse[0].success;
|
||||
var mandantsSuccess = mandantsResponse[0].success;
|
||||
var formSuccess = formResponse[0].success;
|
||||
var departmentsSuccess = departmentsResponse[0].success;
|
||||
var rolesSuccess = rolesResponse[0].success;
|
||||
var einrichtsSuccess = einrichtsResponse[0].success;
|
||||
var fachbereicheSuccess = fachbereicheResponse[0].success;
|
||||
var pdfInlineSuccess = pdfInlineResponse[0].success;
|
||||
var postColorsSuccess = postColorsResponse[0].success;
|
||||
|
||||
if (catSuccess && mandantsSuccess && formSuccess && departmentsSuccess && rolesSuccess && einrichtsSuccess && fachbereicheSuccess && pdfInlineSuccess && postColorsSuccess) {
|
||||
showMessage("Alle Daten erfolgreich gespeichert!", true);
|
||||
} else {
|
||||
var errorMsg = "Fehler beim Speichern:";
|
||||
if (!catSuccess) errorMsg += " Kategorien";
|
||||
if (!mandantsSuccess) errorMsg += " Mandanten";
|
||||
if (!formSuccess) errorMsg += " Formular";
|
||||
if (!departmentsSuccess) errorMsg += " Departments";
|
||||
if (!rolesSuccess) errorMsg += " Rollen";
|
||||
if (!einrichtsSuccess) errorMsg += " Einrichtungen";
|
||||
if (!fachbereicheSuccess) errorMsg += " Fachbereiche";
|
||||
if (!pdfInlineSuccess) errorMsg += " PDF Inline Vorschau";
|
||||
if (!postColorsSuccess) errorMsg += " Farben";
|
||||
showMessage(errorMsg, false);
|
||||
}
|
||||
}).fail(function () {
|
||||
showMessage("Es ist ein Fehler beim Speichern aufgetreten!", false);
|
||||
}).always(function () {
|
||||
$("#tab-settings").removeClass("blurred");
|
||||
});
|
||||
});
|
||||
<?php } ?>
|
||||
|
||||
// Post löschen
|
||||
$("#deletePostBtn").on("click", function() {
|
||||
if (!confirm("<?= addslashes($translation->get("delete_post_confirm")) ?>")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ajax-Aufruf an Deinen neuen Case
|
||||
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||
action: "delete_post",
|
||||
post_id: <?=$postId?>
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
// Weiterleitung nach dem Löschen, z.B. zurück zur Übersicht
|
||||
window.location.href = "?action=Category";
|
||||
} else {
|
||||
showMessage("Fehler beim Löschen: " + (response.error || "Unbekannter Fehler"), false);
|
||||
}
|
||||
}, "json").fail(function() {
|
||||
showMessage("Serverfehler beim Löschen.", false);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<div id="versionList">
|
||||
<?php foreach ($versions as $ver): ?>
|
||||
<?php
|
||||
$selected = ($ver['id'] == $currentVersion['id']) ? "version_selected" : "";
|
||||
// Formatierung der Gültigkeitszeiträume:
|
||||
$validFrom = !empty($ver['valid_from'])
|
||||
? date("d.m.Y H:i", strtotime($ver['valid_from']))
|
||||
: $translation->get("unlimited");
|
||||
$validUntil = !empty($ver['valid_until'])
|
||||
? date("d.m.Y H:i", strtotime($ver['valid_until']))
|
||||
: $translation->get("unlimited");
|
||||
?>
|
||||
<div class="version_item <?= $selected ?>" data-id="<?= $ver['id'] ?>"
|
||||
data-version-number="<?= $ver['version_number'] ?>">
|
||||
|
||||
<div class="version_header">
|
||||
<div>
|
||||
<?= $translation->get("version") ?> <?= $ver['version_number'] ?>
|
||||
- <?= htmlspecialchars($ver['title']) ?>
|
||||
<span class="status <?= $ver['state'] ? 'active' : 'inactive' ?>">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version_validity">
|
||||
<?= $translation->get("valid_from") ?>: <?= $validFrom ?>
|
||||
<?= $translation->get("valid_until") ?>: <?= $validUntil ?>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
|
||||
<span class="deleteVersionBtn red post-action-btn" data-id="<?= $ver['id'] ?>"
|
||||
data-version-number="<?= $ver['version_number'] ?>">
|
||||
✕
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<hr>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
119
module/knowledgecenter_update/Views/post_listform.php
Normal file
119
module/knowledgecenter_update/Views/post_listform.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// post_listform.php
|
||||
|
||||
// Redirect-URL für den Zurück-Button bzw. Navigation
|
||||
$redirectURL = isset($_GET['redirectURL']) ? $_GET['redirectURL'] : '?action=Category';
|
||||
// Kategorien für das Dropdown holen
|
||||
$_isBrOnly = !is_wiki_redakteur() && is_wiki_betriebsrat();
|
||||
if ($_isBrOnly) {
|
||||
$_brCatIds = get_all_betriebsrat_accessible_category_ids();
|
||||
if (empty($_brCatIds)) {
|
||||
$catsQuery = "SELECT NULL LIMIT 0";
|
||||
} else {
|
||||
$_brCatIdList = implode(',', $_brCatIds);
|
||||
$catsQuery = "
|
||||
SELECT c.id, t.title
|
||||
FROM knowledgecenter_categories_update AS c
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON t.category_id = c.id AND t.language_id = $languageId
|
||||
WHERE c.id IN ($_brCatIdList)
|
||||
ORDER BY t.title ASC
|
||||
";
|
||||
}
|
||||
} else {
|
||||
$catsQuery = "
|
||||
SELECT c.id, t.title
|
||||
FROM knowledgecenter_categories_update AS c
|
||||
LEFT JOIN knowledgecenter_category_translations AS t
|
||||
ON t.category_id = c.id AND t.language_id = $languageId
|
||||
ORDER BY t.title ASC
|
||||
";
|
||||
}
|
||||
$catsResult = mysqli_query($GLOBALS['mysql_con'], $catsQuery);
|
||||
$allCategories = [];
|
||||
while ($r = mysqli_fetch_assoc($catsResult)) {
|
||||
$allCategories[] = $r;
|
||||
}
|
||||
?>
|
||||
<div class="titlebar">
|
||||
<div>
|
||||
<a title="<?= $translation->get("back") ?>" href="?action=List">←</a>
|
||||
<h1><?= $translation->get("post_list") ?> <span id="postCount"></span>
|
||||
|
||||
</h1>
|
||||
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||
<a href="?action=NewPost" class="post-action-btn green">+</a>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<div>
|
||||
<select id="categoryFilter">
|
||||
<option value=""><?= $translation->get("all_categories") ?></option>
|
||||
<?php foreach ($allCategories as $cat): ?>
|
||||
<option value="<?= $cat['id'] ?>">
|
||||
<?= htmlspecialchars($cat['title'] ?: "ID:{$cat['id']}") ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input type="search" id="searchQuery" placeholder="<?= $translation->get("search_placeholder") ?>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="postListContainer" class="wiki-list">
|
||||
</div>
|
||||
|
||||
|
||||
<?php
|
||||
$_brCategoryIds = $_isBrOnly ? ($_brCatIds ?? []) : null;
|
||||
$_brCategoryIdsJson = json_encode($_brCategoryIds);
|
||||
?>
|
||||
<script>
|
||||
var myRedirectURL = window.location.href;
|
||||
var debounceTimer;
|
||||
var brCategoryIds = <?= $_brCategoryIdsJson ?>;
|
||||
|
||||
function loadPostList() {
|
||||
var searchQuery = $('#searchQuery').val();
|
||||
var categoryId = $('#categoryFilter').val(); // neu
|
||||
|
||||
$.ajax({
|
||||
url: '/module/knowledgecenter_update/Ajax/Ajax.php',
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'get_post_list',
|
||||
language_code: '<?= $languageCode ?>',
|
||||
language_id: '<?= $languageId ?>',
|
||||
redirectURL: myRedirectURL,
|
||||
query: searchQuery,
|
||||
category_id: categoryId,
|
||||
br_category_ids: brCategoryIds !== null ? JSON.stringify(brCategoryIds) : null
|
||||
},
|
||||
dataType: 'json',
|
||||
beforeSend: function () { $('#overlayLoader').show(); },
|
||||
complete: function () { $('#overlayLoader').hide(); },
|
||||
success: function (response) {
|
||||
if (response.success) {
|
||||
$('#postListContainer').html(response.html);
|
||||
var c = parseInt(response.count || 0, 10);
|
||||
$('#postCount').text('(' + c + ')');
|
||||
} else {
|
||||
alert('Fehler: ' + response.error);
|
||||
}
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
console.error('AJAX-Fehler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
loadPostList();
|
||||
|
||||
$('#searchQuery').on('input', function () {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(loadPostList, 300);
|
||||
});
|
||||
|
||||
$('#categoryFilter').on('change', loadPostList);
|
||||
});
|
||||
</script>
|
||||
1
module/knowledgecenter_update/Views/posts_backend.php
Normal file
1
module/knowledgecenter_update/Views/posts_backend.php
Normal file
@@ -0,0 +1 @@
|
||||
<iframe class="fullscreen" src="/intranet/de/wiki/?action=PostList&embedded=1" frameborder="0"></iframe>
|
||||
59
module/shared_components/quill/quill_resize.css
Normal file
59
module/shared_components/quill/quill_resize.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Drag-Handle Overlay für Quill Image Resize */
|
||||
|
||||
.ql-image-overlay {
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #2684ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ql-image-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border: 2px solid #2684ff;
|
||||
border-radius: 2px;
|
||||
pointer-events: auto;
|
||||
z-index: 10001;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ql-image-handle:hover {
|
||||
background: #2684ff;
|
||||
}
|
||||
|
||||
.ql-image-handle-nw {
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.ql-image-handle-ne {
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.ql-image-handle-sw {
|
||||
left: -6px;
|
||||
bottom: -6px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.ql-image-handle-se {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
/* Während des Drags Text-Selection global unterdrücken */
|
||||
.ql-image-overlay.is-dragging,
|
||||
.ql-image-overlay.is-dragging * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Im Editor selbst soll das Bild visuell ein leichtes Outline bekommen,
|
||||
wenn es aktiv ist — aber CSS kann nicht aus dem Overlay zurückgreifen,
|
||||
daher: ein dünner Schatten ist genug; der gestrichelte Rand am Overlay
|
||||
markiert das aktive Bild klar genug. */
|
||||
363
module/shared_components/quill/quill_resize.js
Normal file
363
module/shared_components/quill/quill_resize.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Quill-Modul: Drag-Resize für Bilder im Editor.
|
||||
*
|
||||
* Aktivierung pro Editor-Instanz via:
|
||||
* modules: { imageResize: true }
|
||||
*
|
||||
* Verhalten:
|
||||
* - Click auf <img> im Editor → 4 Ecken-Handles als position:fixed-Overlay
|
||||
* - mousedown/touchstart auf einem Handle startet den Drag
|
||||
* - Drag aktualisiert width-Attribut + Inline-Style mit !important am <img>
|
||||
* (Inline-Style ist nötig, weil die globale `img { max-width:100% }`-Regel
|
||||
* im Intranet das HTML-width-Attribut überstimmt — Spezifität 0 verliert
|
||||
* gegen jede Author-CSS-Regel auf <img>)
|
||||
* - Aspect Ratio über CSS `height: auto` gehalten
|
||||
*
|
||||
* Persistenz-Pipeline:
|
||||
* 1) ResizableImage-Blot (unten registriert) whitelisted alt/width/height in
|
||||
* Quills formats/image-Override, damit die Attribute den Quill-Save-Pfad
|
||||
* (`quill.update`) überleben.
|
||||
* 2) Pro Quill-Instanz: Clipboard-Matcher für IMG + Monkey-Patch um
|
||||
* `dangerouslyPasteHTML`. Die reparieren den Lade-Pfad, weil Quill
|
||||
* 2.0.0-dev.3 beim HTML→Delta-Parse die Attribute trotz Blot droppt.
|
||||
* Der Monkey-Patch reappliziert width-Attribut UND Inline-!important-Style
|
||||
* am Editor-DOM (gleiches Pattern wie Live-Drag).
|
||||
*
|
||||
* Bewusst klein gehalten. Keine Abhängigkeit zu third-party Resize-Modulen,
|
||||
* die für Quill 1.3.x geschrieben wurden und in 2.0.0-dev.3 unstet sind.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
if (typeof Quill === 'undefined') {
|
||||
console.error('[quill_resize] Quill ist nicht geladen.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ResizableImage-Blot: Quill 2.0.0-dev.3' nativer formats/image strippt
|
||||
// beim Re-Parse alle Attribute außer src. Dieser Blot whitelisted alt,
|
||||
// width, height, damit die per Drag gesetzte Größe den Roundtrip überlebt.
|
||||
//
|
||||
// Quill 2.x' Blots sind ES6-Klassen — Vererbung MUSS via `class ... extends`
|
||||
// erfolgen, nicht via Prototype-Chain (Image.apply würde TypeError werfen,
|
||||
// weil ES6-Classes nicht ohne `new` aufgerufen werden können).
|
||||
//
|
||||
// try/catch: Falls Quill in einer Version vorliegt, die diese Override-API
|
||||
// nicht unterstützt, kippt der Editor nicht — wir loggen und fallen
|
||||
// stillschweigend auf den nativen Image-Blot zurück.
|
||||
if (!global.__ResizableImageRegistered) {
|
||||
try {
|
||||
var Image = Quill.import('formats/image');
|
||||
var ATTRS = ['alt', 'width', 'height'];
|
||||
|
||||
var ResizableImage = class extends Image {
|
||||
static create(value) {
|
||||
var src = value;
|
||||
var extras = null;
|
||||
if (value && typeof value === 'object') {
|
||||
src = value.src || value.url || '';
|
||||
extras = value;
|
||||
}
|
||||
var node = super.create(src);
|
||||
if (extras) {
|
||||
ATTRS.forEach(function (attr) {
|
||||
if (extras[attr] !== undefined && extras[attr] !== null && extras[attr] !== '') {
|
||||
node.setAttribute(attr, extras[attr]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
static formats(domNode) {
|
||||
var base = (typeof super.formats === 'function') ? super.formats(domNode) : {};
|
||||
ATTRS.forEach(function (attr) {
|
||||
if (domNode.hasAttribute(attr)) {
|
||||
base[attr] = domNode.getAttribute(attr);
|
||||
}
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
format(name, value) {
|
||||
if (ATTRS.indexOf(name) > -1) {
|
||||
if (value) {
|
||||
this.domNode.setAttribute(name, value);
|
||||
} else {
|
||||
this.domNode.removeAttribute(name);
|
||||
}
|
||||
} else {
|
||||
super.format(name, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ResizableImage.blotName = 'image';
|
||||
ResizableImage.tagName = 'IMG';
|
||||
|
||||
Quill.register(ResizableImage, true);
|
||||
global.__ResizableImageRegistered = true;
|
||||
global.ResizableImage = ResizableImage;
|
||||
} catch (err) {
|
||||
console.error('[quill_resize] ResizableImage-Blot konnte nicht registriert werden — fallback auf nativen Image-Blot. Resize-Größe geht beim Re-Edit verloren.', err);
|
||||
}
|
||||
}
|
||||
|
||||
var MIN_WIDTH = 50;
|
||||
|
||||
function ImageResize(quill, options) {
|
||||
this.quill = quill;
|
||||
this.editor = quill.root;
|
||||
this.options = options || {};
|
||||
this.activeImg = null;
|
||||
this.overlay = null;
|
||||
this.dragState = null;
|
||||
|
||||
// Methoden binden, damit removeEventListener funktioniert
|
||||
this.onEditorClick = this.onEditorClick.bind(this);
|
||||
this.onDocPointerDown = this.onDocPointerDown.bind(this);
|
||||
this.onReposition = this.onReposition.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
this.onUp = this.onUp.bind(this);
|
||||
|
||||
this.editor.addEventListener('click', this.onEditorClick);
|
||||
document.addEventListener('mousedown', this.onDocPointerDown, true);
|
||||
document.addEventListener('touchstart', this.onDocPointerDown, true);
|
||||
window.addEventListener('resize', this.onReposition);
|
||||
window.addEventListener('scroll', this.onReposition, true);
|
||||
|
||||
// Bei Text-Changes Position anpassen (z.B. wenn Bild verschoben wird)
|
||||
quill.on('text-change', this.onReposition);
|
||||
|
||||
// Quill 2.0.0-dev.3 strippt beim dangerouslyPasteHTML alle <img>-Attribute
|
||||
// außer src. Reader rendert das gespeicherte HTML direkt — also korrekt;
|
||||
// aber im Editor ist die Größe nach Re-Open weg. Zwei Schutzschichten:
|
||||
//
|
||||
// (a) Clipboard-Matcher: Beim HTML→Delta-Parse die width/height/alt
|
||||
// als delta.attributes mitführen. Bei korrekt greifendem
|
||||
// ResizableImage-Blot überlebt das den Render zurück ans DOM.
|
||||
//
|
||||
// (b) Monkey-Patch um dangerouslyPasteHTML: Falls (a) in dev.3 nicht
|
||||
// durchgereicht wird (Embed-format-Hook ignoriert), reapplizieren
|
||||
// wir die Attribute nach dem Paste direkt am Editor-DOM. Idempotent
|
||||
// pro Quill-Instanz via Marker-Property.
|
||||
try {
|
||||
quill.clipboard.addMatcher('IMG', function (node, delta) {
|
||||
if (!node || !delta || !delta.ops || !delta.ops.length) return delta;
|
||||
var op = delta.ops[0];
|
||||
if (!op || !op.insert) return delta;
|
||||
var attrs = op.attributes || {};
|
||||
['alt', 'width', 'height'].forEach(function (a) {
|
||||
if (node.hasAttribute(a) && attrs[a] == null) {
|
||||
attrs[a] = node.getAttribute(a);
|
||||
}
|
||||
});
|
||||
if (Object.keys(attrs).length) op.attributes = attrs;
|
||||
return delta;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[quill_resize] Clipboard-Matcher Setup fehlgeschlagen', e);
|
||||
}
|
||||
|
||||
if (!quill.__pasteHtmlPatched && quill.clipboard
|
||||
&& typeof quill.clipboard.dangerouslyPasteHTML === 'function') {
|
||||
var origPaste = quill.clipboard.dangerouslyPasteHTML;
|
||||
var PASTE_ATTRS = ['alt', 'width', 'height'];
|
||||
quill.clipboard.dangerouslyPasteHTML = function () {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var htmlArg = (typeof args[0] === 'string') ? args[0] : args[1];
|
||||
|
||||
var seq = [];
|
||||
if (typeof htmlArg === 'string' && htmlArg.indexOf('<img') !== -1) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = htmlArg;
|
||||
Array.prototype.forEach.call(tmp.querySelectorAll('img'), function (img) {
|
||||
var entry = { src: img.getAttribute('src') || '', attrs: {} };
|
||||
PASTE_ATTRS.forEach(function (a) {
|
||||
if (img.hasAttribute(a)) entry.attrs[a] = img.getAttribute(a);
|
||||
});
|
||||
seq.push(entry);
|
||||
});
|
||||
}
|
||||
|
||||
var result = origPaste.apply(this, args);
|
||||
|
||||
if (seq.length) {
|
||||
var domImgs = quill.root.querySelectorAll('img');
|
||||
var ix = 0;
|
||||
for (var di = 0; di < domImgs.length && ix < seq.length; di++) {
|
||||
var dimg = domImgs[di];
|
||||
var dsrc = dimg.getAttribute('src') || '';
|
||||
while (ix < seq.length && seq[ix].src !== dsrc) ix++;
|
||||
if (ix < seq.length && seq[ix].src === dsrc) {
|
||||
var entryAttrs = seq[ix].attrs;
|
||||
Object.keys(entryAttrs).forEach(function (k) {
|
||||
if (!dimg.hasAttribute(k)) {
|
||||
dimg.setAttribute(k, entryAttrs[k]);
|
||||
}
|
||||
});
|
||||
// Inline-Style mit !important: das HTML-width-Attribut
|
||||
// hat Spezifität 0 und wird im Intranet von der globalen
|
||||
// mysyde.css-img-Regel überstimmt (oder von Quills internem
|
||||
// Embed-Wrapping). Inline-!important gewinnt garantiert.
|
||||
// Spiegelt das Verhalten von onMove (live-drag) wider.
|
||||
if (entryAttrs.width) {
|
||||
dimg.style.setProperty('width', entryAttrs.width + 'px', 'important');
|
||||
dimg.style.setProperty('height', 'auto', 'important');
|
||||
}
|
||||
ix++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
quill.__pasteHtmlPatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
ImageResize.prototype.onEditorClick = function (e) {
|
||||
if (e.target && e.target.tagName === 'IMG' && this.editor.contains(e.target)) {
|
||||
this.attachTo(e.target);
|
||||
} else {
|
||||
this.detach();
|
||||
}
|
||||
};
|
||||
|
||||
ImageResize.prototype.attachTo = function (img) {
|
||||
if (this.activeImg === img && this.overlay) return;
|
||||
this.detach();
|
||||
this.activeImg = img;
|
||||
this.overlay = this.buildOverlay();
|
||||
document.body.appendChild(this.overlay);
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
ImageResize.prototype.buildOverlay = function () {
|
||||
var self = this;
|
||||
var box = document.createElement('div');
|
||||
box.className = 'ql-image-overlay';
|
||||
|
||||
['nw', 'ne', 'sw', 'se'].forEach(function (corner) {
|
||||
var h = document.createElement('div');
|
||||
h.className = 'ql-image-handle ql-image-handle-' + corner;
|
||||
h.dataset.corner = corner;
|
||||
|
||||
// Corner explizit über Closure binden, nicht über e.currentTarget
|
||||
// (currentTarget kann nach Listener-Return null sein, je nach Browser).
|
||||
var startDrag = function (e) { self.onHandleDown(e, corner); };
|
||||
h.addEventListener('mousedown', startDrag);
|
||||
h.addEventListener('touchstart', startDrag, { passive: false });
|
||||
|
||||
box.appendChild(h);
|
||||
});
|
||||
|
||||
return box;
|
||||
};
|
||||
|
||||
ImageResize.prototype.reposition = function () {
|
||||
if (!this.activeImg || !this.overlay) return;
|
||||
var r = this.activeImg.getBoundingClientRect();
|
||||
var s = this.overlay.style;
|
||||
s.position = 'fixed';
|
||||
s.left = r.left + 'px';
|
||||
s.top = r.top + 'px';
|
||||
s.width = r.width + 'px';
|
||||
s.height = r.height + 'px';
|
||||
// KEIN pointer-events:none hier — die Handles brauchen Klick-Empfang.
|
||||
// Der gestrichelte Rand des Overlays liegt im CSS auf
|
||||
// `.ql-image-overlay` mit pointer-events:none direkt im CSS.
|
||||
s.zIndex = '10000';
|
||||
};
|
||||
|
||||
ImageResize.prototype.onReposition = function () {
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
ImageResize.prototype.onHandleDown = function (e, corner) {
|
||||
if (!this.activeImg) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
|
||||
var r = this.activeImg.getBoundingClientRect();
|
||||
|
||||
this.dragState = {
|
||||
startX: p.clientX,
|
||||
startY: p.clientY,
|
||||
startW: r.width,
|
||||
startH: r.height,
|
||||
corner: corner
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', this.onMove);
|
||||
document.addEventListener('mouseup', this.onUp);
|
||||
document.addEventListener('touchmove', this.onMove, { passive: false });
|
||||
document.addEventListener('touchend', this.onUp);
|
||||
document.addEventListener('touchcancel', this.onUp);
|
||||
};
|
||||
|
||||
ImageResize.prototype.onMove = function (e) {
|
||||
if (!this.dragState || !this.activeImg) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
|
||||
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
|
||||
var dx = p.clientX - this.dragState.startX;
|
||||
|
||||
// Links liegende Handles invertieren das Vorzeichen:
|
||||
// rechts ziehen → dx positiv → image schrumpft (left handle)
|
||||
if (this.dragState.corner === 'nw' || this.dragState.corner === 'sw') {
|
||||
dx = -dx;
|
||||
}
|
||||
|
||||
var editorR = this.editor.getBoundingClientRect();
|
||||
var maxW = Math.max(MIN_WIDTH, editorR.width - 8);
|
||||
var newW = Math.max(MIN_WIDTH, Math.min(maxW, this.dragState.startW + dx));
|
||||
newW = Math.round(newW);
|
||||
|
||||
// width sowohl als HTML-Attribut als auch als Inline-Style setzen.
|
||||
// Inline-Style ist nötig, weil im Intranet eine CSS-Regel mit höherer
|
||||
// Spezifität als das Attribut existiert (an dieser Stelle nicht zu
|
||||
// finden ohne Live-DevTools). Inline-Spezifität (1000) gewinnt gegen
|
||||
// alle nicht-!important CSS-Regeln. setProperty mit 'important' als
|
||||
// Fallback, falls eine !important-Regel im Spiel ist.
|
||||
var widthPx = newW + 'px';
|
||||
this.activeImg.setAttribute('width', String(newW));
|
||||
this.activeImg.style.setProperty('width', widthPx, 'important');
|
||||
this.activeImg.style.setProperty('height', 'auto', 'important');
|
||||
this.activeImg.removeAttribute('height');
|
||||
this.reposition();
|
||||
};
|
||||
|
||||
ImageResize.prototype.onUp = function () {
|
||||
document.removeEventListener('mousemove', this.onMove);
|
||||
document.removeEventListener('mouseup', this.onUp);
|
||||
document.removeEventListener('touchmove', this.onMove);
|
||||
document.removeEventListener('touchend', this.onUp);
|
||||
document.removeEventListener('touchcancel', this.onUp);
|
||||
|
||||
if (this.dragState) {
|
||||
this.dragState = null;
|
||||
try { this.quill.update('user'); } catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
ImageResize.prototype.onDocPointerDown = function (e) {
|
||||
if (!this.activeImg) return;
|
||||
if (this.overlay && this.overlay.contains(e.target)) return;
|
||||
if (e.target === this.activeImg) return;
|
||||
this.detach();
|
||||
};
|
||||
|
||||
ImageResize.prototype.detach = function () {
|
||||
if (this.overlay && this.overlay.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
this.overlay = null;
|
||||
this.activeImg = null;
|
||||
this.dragState = null;
|
||||
};
|
||||
|
||||
Quill.register('modules/imageResize', ImageResize);
|
||||
global.QuillImageResize = ImageResize;
|
||||
})(window);
|
||||
108
module/shared_components/quill/toolbar_presets.js
Normal file
108
module/shared_components/quill/toolbar_presets.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Geteilte Toolbar-Presets für Quill-Editoren (Wiki, Forum).
|
||||
*
|
||||
* Jeder Preset ist eine Factory-Funktion, die ein frisches Array
|
||||
* liefert. Grund: Quill kann das Modules-Objekt während der Init
|
||||
* mutieren — bei mehreren Editor-Instanzen auf einer Seite (z.B.
|
||||
* Wiki mit Sprach-Tabs) würden sie sich sonst gegenseitig
|
||||
* verändern.
|
||||
*
|
||||
* Verwendung pro Editor:
|
||||
*
|
||||
* container: window.QuillToolbarPresets.full()
|
||||
*
|
||||
* Bewusst nicht enthalten: `code` (inline) — nur `code-block`,
|
||||
* weil Multi-Line-Code die häufige Nutzung ist.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
global.QuillToolbarPresets = {
|
||||
// Vollständige Toolbar — Goldstandard für Wiki und Forum
|
||||
full: function () {
|
||||
return [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ align: [] }],
|
||||
[
|
||||
{ list: 'ordered' }, { list: 'bullet' },
|
||||
{ indent: '-1' }, { indent: '+1' }
|
||||
],
|
||||
['link', 'image', 'video'],
|
||||
['code-block', 'table'],
|
||||
['clean']
|
||||
];
|
||||
},
|
||||
|
||||
// Compact-Toolbar — wie full(), aber OHNE Image und Video.
|
||||
// Genutzt in Modulen, die kein Image-Backend haben (Tasks, Products).
|
||||
compact: function () {
|
||||
return [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ align: [] }],
|
||||
[
|
||||
{ list: 'ordered' }, { list: 'bullet' },
|
||||
{ indent: '-1' }, { indent: '+1' }
|
||||
],
|
||||
['link'],
|
||||
['code-block', 'table'],
|
||||
['clean']
|
||||
];
|
||||
},
|
||||
|
||||
// Better-Table-Modul-Konfiguration. Identisch über alle Editoren —
|
||||
// deutsche Operations-Menu-Labels und der Hintergrundfarben-Picker
|
||||
// für Tabellenzellen.
|
||||
betterTableConfig: function () {
|
||||
return {
|
||||
operationMenu: {
|
||||
items: {
|
||||
insertRowUp: { text: 'Zeile oberhalb einfügen' },
|
||||
insertRowDown: { text: 'Zeile unterhalb einfügen' },
|
||||
insertColumnLeft: { text: 'Spalte links einfügen' },
|
||||
insertColumnRight: { text: 'Spalte rechts einfügen' },
|
||||
mergeCells: { text: 'Zellen zusammenführen' },
|
||||
unmergeCells: { text: 'Zellen teilen' },
|
||||
deleteRow: { text: 'Zeile löschen' },
|
||||
deleteColumn: { text: 'Spalte löschen' },
|
||||
deleteTable: { text: 'Tabelle löschen' }
|
||||
},
|
||||
color: {
|
||||
colors: [
|
||||
'#f8f9fa', '#e9ecef', '#dee2e6', '#ced4da',
|
||||
'#adb5bd', '#6c757d',
|
||||
'green', 'red', 'yellow', 'blue',
|
||||
'white', 'black', 'orange'
|
||||
],
|
||||
text: 'Hintergrundfarbe'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Vorlage für künftige Erweiterungen — derzeit nicht aktiv:
|
||||
//
|
||||
// compact: function () {
|
||||
// // wie full, aber ohne 'table' — z.B. für Comment-Felder
|
||||
// var t = this.full();
|
||||
// var idx = t.findIndex(function (row) { return row.indexOf('table') > -1; });
|
||||
// if (idx > -1) t[idx] = ['code-block'];
|
||||
// return t;
|
||||
// },
|
||||
//
|
||||
// minimal: function () {
|
||||
// return [
|
||||
// [{ header: [2, 3, false] }],
|
||||
// ['bold', 'italic', 'underline'],
|
||||
// [{ list: 'ordered' }, { list: 'bullet' }],
|
||||
// ['link'],
|
||||
// ['clean']
|
||||
// ];
|
||||
// }
|
||||
};
|
||||
})(window);
|
||||
99
module/tasks/README.md
Normal file
99
module/tasks/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Task-Cert Modul
|
||||
|
||||
Aufgabenverwaltung mit Zielgruppen-Zuweisung, Erledigungsmethoden, Freigabeprozess, Zertifikaten und Eskalationen.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# 1. Schema initialisieren (Erstinstallation)
|
||||
mysql -u root -p intranet < sql/init.sql
|
||||
|
||||
# 2. Migrationen anwenden (bestehende Installation)
|
||||
mysql -u root -p intranet < sql/migrations/2026-03-17_add_task_responsible.sql
|
||||
mysql -u root -p intranet < sql/migrations/2026-03-18_add_grace_period.sql
|
||||
mysql -u root -p intranet < sql/migrations/2026-03-18_add_form_field_prefill.sql
|
||||
|
||||
# 3. Cronjobs einrichten (siehe docs/cronjobs.md)
|
||||
```
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
tasks/
|
||||
bootstrap.php DI-Container, URL-Mapping, Asset-Helper
|
||||
pages/
|
||||
admin/ Admin-Views (task_list, task_form, task_detail, ...)
|
||||
user/ User-Views (my_tasks, task_detail, Widgets)
|
||||
actions/ View-Controller (Datenaufbereitung pro Seite)
|
||||
endpoints/
|
||||
admin/ Schreibende Admin-Endpunkte (POST/CLI)
|
||||
user/ Schreibende User-Endpunkte (POST)
|
||||
service/ Business-Logik (10 Services)
|
||||
repo/ DB-Zugriff (Repository-Pattern)
|
||||
lib/ Utility-Klassen (Auth, DB, Request, View, ...)
|
||||
sql/
|
||||
init.sql Vollständiges Schema (15 Tabellen)
|
||||
migrations/ Inkrementelle Migrationen
|
||||
assets/
|
||||
css/ task_cert.css, task_cert_admin.css, task_cert_user.css
|
||||
js/ task_cert_admin_form_wizard.js
|
||||
vendor/ MultiSelect-Komponente
|
||||
cli/ Cron-CLI-Skripte
|
||||
logs/ Cron-Logs, Lockfiles, Heartbeat-Dateien
|
||||
doc/ Architektur- und Lifecycle-Dokumentation
|
||||
docs/ Betriebsdokumentation (Cronjobs)
|
||||
```
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [Architektur & Lifecycle](doc/task-cert-module-lifecycle.md) | Services, Datenmodell, Status-Flows, Troubleshooting |
|
||||
| [Cronjobs](docs/cronjobs.md) | Einrichtung, Intervalle, CLI-Befehle |
|
||||
| [Testfälle](doc/task-cert-test-cases.md) | 17 Szenarien mit erwarteten Ergebnissen |
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Verantwortung |
|
||||
|---|---|
|
||||
| `TaskDefinitionService` | Aufgaben CRUD, Zyklen-Reset, Audit-Log |
|
||||
| `AssignmentResolverService` | Zuweisungen materialisieren & synchronisieren |
|
||||
| `CycleCalculatorService` | Zyklusberechnung (Kalender- & Erledigungsbasis) |
|
||||
| `CompletionService` | User-Einreichungen, Methodenabschluss, Completion-Guard |
|
||||
| `ApprovalService` | Manuelle Freigabe/Ablehnung |
|
||||
| `CertificateService` | Zertifikate ausstellen/widerrufen |
|
||||
| `EscalationService` | Eskalationsstufen, Grace-Period-Logik |
|
||||
| `TaskMonitoringService` | Admin-Dashboard KPIs |
|
||||
| `TaskCategoryService` | Kategorienverwaltung |
|
||||
| `TaskLookupService` | Stammdaten-Lookups (Kontakte, Mandanten, ...) |
|
||||
|
||||
## Wichtige Konzepte
|
||||
|
||||
### Rhythmus-Basis
|
||||
- **Kalenderbasiert** (`calendar`): Feste Zyklen ab Startdatum (z.B. jeden Monat)
|
||||
- **Erledigungsbasiert** (`completion`): Nächster Zyklus individuell nach Erledigung
|
||||
|
||||
### Karenzzeit (Grace Period)
|
||||
Neue Zuweisungen erhalten eine `grace_until`-Frist. Die Eskalation greift erst nach `max(due_at, grace_until)`. Konfigurierbar pro Aufgabe in der Erinnerungsrichtlinie (`grace_days`, Default: 7).
|
||||
|
||||
### Formular-Vorbefüllung (Prefill)
|
||||
Textfelder in Formularen können mit Kontaktdaten des Nutzers vorbefüllt werden. Konfigurierbar pro Feld via `prefill_column`. Erlaubte Spalten definiert in `lib/ContactPrefillColumns.php`.
|
||||
|
||||
### Completion-Guard
|
||||
Abgeschlossene Assignments sind gesperrt — erneutes Einreichen wird sowohl im Backend (Exception) als auch im Frontend (disabled Buttons) verhindert.
|
||||
|
||||
### Sofortige Folgezyklus-Erstellung
|
||||
Bei erledigungsbasierten Aufgaben wird der nächste Zyklus direkt nach Completion erstellt (nicht erst beim nächsten Cron-Lauf). Deduplizierung über `createIfNotExists` + Unique-Key.
|
||||
|
||||
### Cron-Heartbeat
|
||||
Erfolgreiche Cron-Läufe schreiben Timestamps nach `logs/heartbeat/*.json`. Status sichtbar am Ende der Admin-Aufgabenliste. Klasse: `lib/CronHeartbeat.php`.
|
||||
|
||||
## Cronjobs (Kurzfassung)
|
||||
|
||||
```crontab
|
||||
*/15 * * * * php /path/to/tasks/endpoints/admin/materialize_assignments.php
|
||||
*/30 * * * * php /path/to/tasks/endpoints/admin/recalculate_escalations.php --apply
|
||||
0 2 * * * php /path/to/tasks/endpoints/admin/sync_assignments.php --apply
|
||||
```
|
||||
|
||||
Details: [docs/cronjobs.md](docs/cronjobs.md)
|
||||
29
module/tasks/actions/admin/approval_queue_action.php
Normal file
29
module/tasks/actions/admin/approval_queue_action.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_approval_queue_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
'submissions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$taskIdFilter = TaskCertRequest::queryInt('task_id', 0);
|
||||
$searchQuery = TaskCertRequest::queryString('q', '');
|
||||
|
||||
$services = task_cert_services();
|
||||
$submissions = $services['submissionRepo']->listPendingApprovals($taskIdFilter, $searchQuery);
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'submissions' => $submissions,
|
||||
'task_id_filter' => $taskIdFilter,
|
||||
'search_query' => $searchQuery,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
40
module/tasks/actions/admin/escalation_dashboard_action.php
Normal file
40
module/tasks/actions/admin/escalation_dashboard_action.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_escalation_dashboard_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
'assignments' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$didRecalculate = false;
|
||||
$searchQuery = TaskCertRequest::queryString('q', '');
|
||||
|
||||
if (TaskCertRequest::queryInt('recalc', 0) === 1) {
|
||||
$services['escalationService']->recalculateEscalations();
|
||||
$didRecalculate = true;
|
||||
}
|
||||
|
||||
$assignments = $services['assignmentRepo']->listForAdmin(0, '', $searchQuery);
|
||||
$assignments = array_values(array_filter(
|
||||
$assignments,
|
||||
static function (array $row): bool {
|
||||
return (int) ($row['escalation_level'] ?? 0) > 0;
|
||||
}
|
||||
));
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'assignments' => $assignments,
|
||||
'did_recalculate' => $didRecalculate,
|
||||
'search_query' => $searchQuery,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
69
module/tasks/actions/admin/task_categories_action.php
Normal file
69
module/tasks/actions/admin/task_categories_action.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_task_categories_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
'categories' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$searchQuery = TaskCertRequest::queryString('q', '');
|
||||
$stateFilter = TaskCertRequest::queryString('state', 'active');
|
||||
if (!in_array($stateFilter, ['active', 'inactive', 'all'], true)) {
|
||||
$stateFilter = 'active';
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$categoryService = $services['taskCategoryService'] ?? null;
|
||||
if (!$categoryService instanceof TaskCategoryService) {
|
||||
return [
|
||||
'error' => 'Kategorie-Service nicht verfügbar.',
|
||||
'categories' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$categories = $categoryService->listForAdmin($searchQuery, $stateFilter);
|
||||
|
||||
$currentQuery = ['state' => $stateFilter];
|
||||
if ($searchQuery !== '') {
|
||||
$currentQuery['q'] = $searchQuery;
|
||||
}
|
||||
|
||||
$flashSuccess = null;
|
||||
$flashError = null;
|
||||
|
||||
if (TaskCertRequest::queryInt('cat_done', 0) === 1) {
|
||||
$action = TaskCertRequest::queryString('cat_action', '');
|
||||
$flashSuccessMap = [
|
||||
'category_create' => 'Kategorie wurde angelegt.',
|
||||
'category_update' => 'Kategorie wurde aktualisiert.',
|
||||
'category_activate' => 'Kategorie wurde aktiviert.',
|
||||
'category_deactivate' => 'Kategorie wurde deaktiviert.',
|
||||
'category_delete' => 'Kategorie wurde gelöscht.',
|
||||
];
|
||||
|
||||
$flashSuccess = $flashSuccessMap[$action] ?? 'Änderung wurde gespeichert.';
|
||||
}
|
||||
|
||||
$errorParam = TaskCertRequest::queryString('cat_error', '');
|
||||
if ($errorParam !== '') {
|
||||
$flashError = $errorParam;
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'categories' => $categories,
|
||||
'search_query' => $searchQuery,
|
||||
'state_filter' => $stateFilter,
|
||||
'current_url' => task_cert_view_url('admin.task_categories', $currentQuery),
|
||||
'flash_success' => $flashSuccess,
|
||||
'flash_error' => $flashError,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
48
module/tasks/actions/admin/task_detail_action.php
Normal file
48
module/tasks/actions/admin/task_detail_action.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_task_detail_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
];
|
||||
}
|
||||
|
||||
$taskId = TaskCertRequest::queryInt('id', 0);
|
||||
if ($taskId <= 0) {
|
||||
return [
|
||||
'error' => 'Ungültige Aufgaben-ID.',
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$task = $services['taskDefinitionService']->getTaskDetail($taskId);
|
||||
if ($task === null) {
|
||||
return [
|
||||
'error' => 'Aufgabe nicht gefunden.',
|
||||
];
|
||||
}
|
||||
|
||||
$monitoringService = $services['taskMonitoringService'] ?? null;
|
||||
if (!$monitoringService instanceof TaskMonitoringService) {
|
||||
return [
|
||||
'error' => 'Monitoring-Service konnte nicht initialisiert werden.',
|
||||
];
|
||||
}
|
||||
|
||||
$dashboard = $monitoringService->buildDashboard($task, [
|
||||
'cycle_key' => TaskCertRequest::queryString('cycle_key', ''),
|
||||
'tab' => TaskCertRequest::queryString('tab', 'overview'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'task' => $task,
|
||||
'dashboard' => $dashboard,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
273
module/tasks/actions/admin/task_form_action.php
Normal file
273
module/tasks/actions/admin/task_form_action.php
Normal file
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_task_form_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
];
|
||||
}
|
||||
|
||||
$taskId = TaskCertRequest::queryInt('id', 0);
|
||||
$services = task_cert_services();
|
||||
|
||||
$task = null;
|
||||
$rules = [
|
||||
'methods' => [],
|
||||
'target_rules' => [],
|
||||
'target_overrides' => [],
|
||||
'reminder_policy' => [
|
||||
'base_mode' => 'due_date',
|
||||
'first_escalation_days' => 0,
|
||||
'step_days' => 7,
|
||||
'max_level' => 3,
|
||||
'active' => 1,
|
||||
],
|
||||
];
|
||||
|
||||
if ($taskId > 0) {
|
||||
$task = $services['taskDefinitionService']->getTaskDetail($taskId);
|
||||
if ($task === null) {
|
||||
return [
|
||||
'error' => 'Aufgabe nicht gefunden.',
|
||||
];
|
||||
}
|
||||
|
||||
$rules = $task['rules'] ?? $rules;
|
||||
}
|
||||
|
||||
$methodConfig = task_cert_normalize_method_config($rules['methods'] ?? []);
|
||||
$targetSelection = task_cert_normalize_target_selection($rules['target_rules'] ?? [], $rules['target_overrides'] ?? []);
|
||||
$targetSelection['responsible_contact_ids'] = is_array($rules['responsible_contact_ids'] ?? null)
|
||||
? array_values(array_unique(array_map('intval', $rules['responsible_contact_ids'])))
|
||||
: [];
|
||||
|
||||
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
|
||||
$lookupService = $services['lookupService'] ?? null;
|
||||
$lookup = ($lookupService instanceof TaskLookupService)
|
||||
? $lookupService->getAdminFormLookups($languageId)
|
||||
: [
|
||||
'task_categories' => [],
|
||||
'mandants' => [],
|
||||
'departments' => [],
|
||||
'roles' => [],
|
||||
'contacts' => [],
|
||||
'knowledge_posts' => [],
|
||||
];
|
||||
|
||||
$taskCategoryId = (int) (($task['task_category_id'] ?? 0));
|
||||
if ($taskCategoryId > 0) {
|
||||
$taskCategories = is_array($lookup['task_categories'] ?? null) ? $lookup['task_categories'] : [];
|
||||
$hasSelectedCategory = false;
|
||||
foreach ($taskCategories as $category) {
|
||||
if ((int) ($category['id'] ?? 0) === $taskCategoryId) {
|
||||
$hasSelectedCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasSelectedCategory) {
|
||||
$taskCategories[] = [
|
||||
'id' => $taskCategoryId,
|
||||
'name' => 'Kategorie #' . $taskCategoryId . ' (nicht gefunden)',
|
||||
'slug' => '',
|
||||
'sort_order' => 999999,
|
||||
'active' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$lookup['task_categories'] = $taskCategories;
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'task' => $task,
|
||||
'rules' => $rules,
|
||||
'method_config' => $methodConfig,
|
||||
'target_selection' => $targetSelection,
|
||||
'lookup' => $lookup,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
|
||||
function task_cert_normalize_method_config(array $methods): array
|
||||
{
|
||||
$types = TaskCertConstants::METHOD_TYPES;
|
||||
$enabled = array_fill_keys($types, 0);
|
||||
|
||||
$config = [
|
||||
'enabled' => $enabled,
|
||||
'knowledge_post_ids' => [],
|
||||
'form_fields' => [],
|
||||
'quiz_questions' => [],
|
||||
];
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$type = (string) ($method['method_type'] ?? '');
|
||||
if (!array_key_exists($type, $config['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config['enabled'][$type] = ((int) ($method['active'] ?? 1) === 1) ? 1 : 0;
|
||||
|
||||
if ($type === 'knowledge_read') {
|
||||
foreach (($method['knowledge_posts'] ?? []) as $post) {
|
||||
$postId = (int) ($post['knowledge_post_id'] ?? 0);
|
||||
if ($postId > 0) {
|
||||
$config['knowledge_post_ids'][] = $postId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'form_submit') {
|
||||
$fields = is_array($method['form_fields'] ?? null) ? $method['form_fields'] : [];
|
||||
foreach ($fields as $field) {
|
||||
$options = [];
|
||||
foreach ((array) ($field['options'] ?? []) as $option) {
|
||||
if (is_array($option)) {
|
||||
$value = trim((string) ($option['value'] ?? $option['label'] ?? ''));
|
||||
if ($value !== '') {
|
||||
$options[] = $value;
|
||||
}
|
||||
} else {
|
||||
$value = trim((string) $option);
|
||||
if ($value !== '') {
|
||||
$options[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$config['form_fields'][] = [
|
||||
'field_key' => (string) ($field['field_key'] ?? ''),
|
||||
'label' => (string) ($field['label'] ?? ''),
|
||||
'field_type' => (string) ($field['field_type'] ?? 'text'),
|
||||
'is_required' => (int) ($field['is_required'] ?? 0),
|
||||
'placeholder' => (string) ($field['placeholder'] ?? ''),
|
||||
'prefill_column' => (string) ($field['prefill_column'] ?? ''),
|
||||
'options_text' => implode(', ', $options),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'quiz') {
|
||||
foreach ((array) ($method['quiz_questions'] ?? []) as $question) {
|
||||
$normalizedQuestion = [
|
||||
'question_text' => (string) ($question['question_text'] ?? ''),
|
||||
'option_a' => '',
|
||||
'option_b' => '',
|
||||
'option_c' => '',
|
||||
'option_d' => '',
|
||||
'correct_option' => 'a',
|
||||
];
|
||||
|
||||
$options = array_values((array) ($question['options'] ?? []));
|
||||
$letters = ['a', 'b', 'c', 'd'];
|
||||
|
||||
foreach ($letters as $index => $letter) {
|
||||
if (!isset($options[$index])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$option = $options[$index];
|
||||
$normalizedQuestion['option_' . $letter] = (string) ($option['option_text'] ?? '');
|
||||
|
||||
if ((int) ($option['is_correct'] ?? 0) === 1) {
|
||||
$normalizedQuestion['correct_option'] = $letter;
|
||||
}
|
||||
}
|
||||
|
||||
$config['quiz_questions'][] = $normalizedQuestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$config['knowledge_post_ids'] = array_values(array_unique(array_map('intval', $config['knowledge_post_ids'])));
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function task_cert_normalize_target_selection(array $targetRules, array $targetOverrides): array
|
||||
{
|
||||
$selection = [
|
||||
'mandant_ids' => [],
|
||||
'department_ids' => [],
|
||||
'role_ids' => [],
|
||||
'einricht_ids' => [],
|
||||
'fachbereich_ids' => [],
|
||||
'override_include_contact_ids' => [],
|
||||
'override_exclude_contact_ids' => [],
|
||||
'override_include_reason' => '',
|
||||
'override_exclude_reason' => '',
|
||||
];
|
||||
|
||||
foreach ($targetRules as $rule) {
|
||||
$mandantId = (int) ($rule['main_mandant_id'] ?? 0);
|
||||
$departmentId = (int) ($rule['main_department_id'] ?? 0);
|
||||
$roleId = (int) ($rule['main_role_id'] ?? 0);
|
||||
$einrichtId = (int) ($rule['main_einricht_id'] ?? 0);
|
||||
$fachbereichId = (int) ($rule['main_bereich_id'] ?? 0);
|
||||
|
||||
if ($mandantId > 0) {
|
||||
$selection['mandant_ids'][] = $mandantId;
|
||||
}
|
||||
|
||||
if ($departmentId > 0) {
|
||||
$selection['department_ids'][] = $departmentId;
|
||||
}
|
||||
|
||||
if ($roleId > 0) {
|
||||
$selection['role_ids'][] = $roleId;
|
||||
}
|
||||
|
||||
if ($einrichtId > 0) {
|
||||
$selection['einricht_ids'][] = $einrichtId;
|
||||
}
|
||||
|
||||
if ($fachbereichId > 0) {
|
||||
$selection['fachbereich_ids'][] = $fachbereichId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($targetOverrides as $override) {
|
||||
$contactId = (int) ($override['main_contact_id'] ?? 0);
|
||||
if ($contactId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($override['override_type'] ?? '');
|
||||
if ($type === 'include') {
|
||||
$selection['override_include_contact_ids'][] = $contactId;
|
||||
if ($selection['override_include_reason'] === '') {
|
||||
$selection['override_include_reason'] = (string) ($override['reason'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'exclude') {
|
||||
$selection['override_exclude_contact_ids'][] = $contactId;
|
||||
if ($selection['override_exclude_reason'] === '') {
|
||||
$selection['override_exclude_reason'] = (string) ($override['reason'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$arrayKeys = [
|
||||
'mandant_ids',
|
||||
'department_ids',
|
||||
'role_ids',
|
||||
'einricht_ids',
|
||||
'fachbereich_ids',
|
||||
'override_include_contact_ids',
|
||||
'override_exclude_contact_ids',
|
||||
];
|
||||
|
||||
foreach ($arrayKeys as $key) {
|
||||
$values = is_array($selection[$key] ?? null) ? $selection[$key] : [];
|
||||
$selection[$key] = array_values(array_unique(array_map('intval', $values)));
|
||||
}
|
||||
|
||||
return $selection;
|
||||
}
|
||||
70
module/tasks/actions/admin/task_list_action.php
Normal file
70
module/tasks/actions/admin/task_list_action.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_admin_task_list_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für die Aufgaben-Administration.',
|
||||
'tasks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$searchQuery = TaskCertRequest::queryString('q', '');
|
||||
$categoryIdFilter = TaskCertRequest::queryInt('category_id', 0);
|
||||
if ($categoryIdFilter < 0) {
|
||||
$categoryIdFilter = 0;
|
||||
}
|
||||
|
||||
$tasks = $services['taskDefinitionService']->listTasksForAdmin($searchQuery, $categoryIdFilter);
|
||||
$categoryFilter = null;
|
||||
|
||||
if ($categoryIdFilter > 0) {
|
||||
$categoryService = $services['taskCategoryService'] ?? null;
|
||||
if ($categoryService instanceof TaskCategoryService) {
|
||||
$categoryFilter = $categoryService->getById($categoryIdFilter);
|
||||
}
|
||||
}
|
||||
|
||||
$myTasks = [];
|
||||
$contactId = $auth->contactId();
|
||||
if ($contactId > 0) {
|
||||
$myTasks = $services['taskDefinitionService']->listTasksForResponsible($contactId);
|
||||
}
|
||||
|
||||
$lookupRepo = $services['lookupRepo'] ?? null;
|
||||
|
||||
$selectedContactId = TaskCertRequest::queryInt('contact_id', 0);
|
||||
$contactAssignments = [];
|
||||
$selectedContactName = '';
|
||||
if ($selectedContactId > 0 && $lookupRepo instanceof TaskLookupRepository) {
|
||||
$contact = $lookupRepo->getContactById($selectedContactId);
|
||||
if (is_array($contact)) {
|
||||
$selectedContactName = trim((string) ($contact['name'] ?? ''));
|
||||
if ($selectedContactName === '') {
|
||||
$selectedContactName = trim((string) ($contact['email'] ?? 'Kontakt #' . $selectedContactId));
|
||||
}
|
||||
}
|
||||
if ($selectedContactName === '') {
|
||||
$selectedContactName = 'Kontakt #' . $selectedContactId;
|
||||
}
|
||||
$contactAssignments = $services['assignmentRepo']->listByContactId($selectedContactId);
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'tasks' => $tasks,
|
||||
'my_tasks' => $myTasks,
|
||||
'search_query' => $searchQuery,
|
||||
'category_id_filter' => $categoryIdFilter,
|
||||
'category_filter' => $categoryFilter,
|
||||
'selected_contact_id' => $selectedContactId,
|
||||
'selected_contact_name' => $selectedContactName,
|
||||
'contact_assignments' => $contactAssignments,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_user_escalation_overlay_widget_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
return [
|
||||
'error' => null,
|
||||
'viewer_contact_id' => 0,
|
||||
'escalated_count' => 0,
|
||||
'my_tasks_url' => task_cert_view_url('user.my_tasks'),
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
|
||||
|
||||
$escalatedCount = 0;
|
||||
foreach ($assignments as $assignment) {
|
||||
$status = (string) ($assignment['status'] ?? '');
|
||||
if (in_array($status, ['completed', 'expired'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($assignment['escalation_level'] ?? 0) > 0) {
|
||||
$escalatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'viewer_contact_id' => $auth->contactId(),
|
||||
'escalated_count' => $escalatedCount,
|
||||
'my_tasks_url' => task_cert_view_url('user.my_tasks'),
|
||||
];
|
||||
}
|
||||
|
||||
34
module/tasks/actions/user/my_tasks_action.php
Normal file
34
module/tasks/actions/user/my_tasks_action.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_user_my_tasks_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
return [
|
||||
'error' => 'Nicht eingeloggt.',
|
||||
'assignments' => [],
|
||||
'certificate_summary_by_task' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
|
||||
|
||||
$taskIds = [];
|
||||
foreach ($assignments as $assignment) {
|
||||
$taskId = (int) ($assignment['task_id'] ?? 0);
|
||||
if ($taskId > 0) {
|
||||
$taskIds[] = $taskId;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'assignments' => $assignments,
|
||||
'certificate_summary_by_task' => $services['certificateService']->getUserTaskCertificateSummary($auth->contactId(), $taskIds),
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
95
module/tasks/actions/user/overdue_widget_action.php
Normal file
95
module/tasks/actions/user/overdue_widget_action.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_user_overdue_widget_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
return [
|
||||
'error' => 'Nicht eingeloggt.',
|
||||
'assignments' => [],
|
||||
'has_more' => false,
|
||||
'total_count' => 0,
|
||||
'limit' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$limit = max(1, min(50, TaskCertRequest::queryInt('limit', 8)));
|
||||
$services = task_cert_services();
|
||||
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$attentionAssignments = [];
|
||||
foreach ($assignments as $assignment) {
|
||||
$status = (string) ($assignment['status'] ?? '');
|
||||
if (in_array($status, ['completed', 'expired'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isEscalated = (int) ($assignment['escalation_level'] ?? 0) > 0;
|
||||
$isOverdueStatus = $status === 'overdue';
|
||||
|
||||
$isOverdueByDate = false;
|
||||
$dueAtRaw = trim((string) ($assignment['due_at'] ?? ''));
|
||||
if ($dueAtRaw !== '') {
|
||||
try {
|
||||
$isOverdueByDate = (new DateTimeImmutable($dueAtRaw)) < $now;
|
||||
} catch (Throwable $throwable) {
|
||||
$isOverdueByDate = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isEscalated && !$isOverdueStatus && !$isOverdueByDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignment['is_escalated'] = $isEscalated ? 1 : 0;
|
||||
$assignment['is_overdue_by_date'] = $isOverdueByDate ? 1 : 0;
|
||||
$attentionAssignments[] = $assignment;
|
||||
}
|
||||
|
||||
usort($attentionAssignments, static function (array $left, array $right): int {
|
||||
$leftEscalation = (int) ($left['escalation_level'] ?? 0);
|
||||
$rightEscalation = (int) ($right['escalation_level'] ?? 0);
|
||||
if ($leftEscalation !== $rightEscalation) {
|
||||
return $rightEscalation <=> $leftEscalation;
|
||||
}
|
||||
|
||||
$leftOverdue = (int) ($left['is_overdue_by_date'] ?? 0);
|
||||
$rightOverdue = (int) ($right['is_overdue_by_date'] ?? 0);
|
||||
if ($leftOverdue !== $rightOverdue) {
|
||||
return $rightOverdue <=> $leftOverdue;
|
||||
}
|
||||
|
||||
$leftDue = trim((string) ($left['due_at'] ?? ''));
|
||||
$rightDue = trim((string) ($right['due_at'] ?? ''));
|
||||
if ($leftDue === '' && $rightDue !== '') {
|
||||
return 1;
|
||||
}
|
||||
if ($leftDue !== '' && $rightDue === '') {
|
||||
return -1;
|
||||
}
|
||||
if ($leftDue !== '' && $rightDue !== '' && $leftDue !== $rightDue) {
|
||||
return strcmp($leftDue, $rightDue);
|
||||
}
|
||||
|
||||
return ((int) ($right['id'] ?? 0)) <=> ((int) ($left['id'] ?? 0));
|
||||
});
|
||||
|
||||
$totalCount = count($attentionAssignments);
|
||||
$hasMore = $totalCount > $limit;
|
||||
if ($hasMore) {
|
||||
$attentionAssignments = array_slice($attentionAssignments, 0, $limit);
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'assignments' => $attentionAssignments,
|
||||
'has_more' => $hasMore,
|
||||
'total_count' => $totalCount,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
|
||||
62
module/tasks/actions/user/summary_widget_action.php
Normal file
62
module/tasks/actions/user/summary_widget_action.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_user_summary_widget_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
return [
|
||||
'error' => null,
|
||||
'open_count' => 0,
|
||||
'overdue_count' => 0,
|
||||
'escalated_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$assignments = $services['assignmentRepo']->listForUser($auth->contactId());
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$openCount = 0;
|
||||
$overdueCount = 0;
|
||||
$escalatedCount = 0;
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$status = (string) ($assignment['status'] ?? '');
|
||||
if (in_array($status, ['completed', 'expired'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$openCount++;
|
||||
|
||||
$isOverdue = $status === 'overdue';
|
||||
if (!$isOverdue) {
|
||||
$dueAtRaw = trim((string) ($assignment['due_at'] ?? ''));
|
||||
if ($dueAtRaw !== '') {
|
||||
try {
|
||||
$isOverdue = (new DateTimeImmutable($dueAtRaw)) < $now;
|
||||
} catch (Throwable $throwable) {
|
||||
$isOverdue = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($isOverdue) {
|
||||
$overdueCount++;
|
||||
}
|
||||
|
||||
if ((int) ($assignment['escalation_level'] ?? 0) > 0) {
|
||||
$escalatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'open_count' => $openCount,
|
||||
'overdue_count' => $overdueCount,
|
||||
'escalated_count' => $escalatedCount,
|
||||
];
|
||||
}
|
||||
|
||||
125
module/tasks/actions/user/task_detail_action.php
Normal file
125
module/tasks/actions/user/task_detail_action.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
function task_cert_user_task_detail_action(): array
|
||||
{
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
return [
|
||||
'error' => 'Nicht eingeloggt.',
|
||||
];
|
||||
}
|
||||
|
||||
$assignmentId = TaskCertRequest::queryInt('assignment_id', 0);
|
||||
if ($assignmentId <= 0) {
|
||||
return [
|
||||
'error' => 'Ungültige Assignment-ID.',
|
||||
];
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$assignment = $services['assignmentRepo']->findById($assignmentId);
|
||||
|
||||
if ($assignment === null) {
|
||||
return [
|
||||
'error' => 'Assignment nicht gefunden.',
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) $assignment['main_contact_id'] !== $auth->contactId()) {
|
||||
return [
|
||||
'error' => 'Keine Berechtigung für dieses Assignment.',
|
||||
];
|
||||
}
|
||||
|
||||
$task = $services['definitionRepo']->findById((int) $assignment['task_id']);
|
||||
if ($task === null) {
|
||||
return [
|
||||
'error' => 'Aufgabe nicht gefunden.',
|
||||
];
|
||||
}
|
||||
|
||||
$methods = $services['ruleRepo']->getMethodsByTask((int) $assignment['task_id']);
|
||||
$methods = array_values(array_filter($methods, static function (array $method): bool {
|
||||
return (int) ($method['active'] ?? 1) === 1;
|
||||
}));
|
||||
$submissions = $services['submissionRepo']->listByAssignment($assignmentId);
|
||||
|
||||
$postIds = [];
|
||||
foreach ($methods as $method) {
|
||||
if ((string) ($method['method_type'] ?? '') !== 'knowledge_read') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) ($method['knowledge_posts'] ?? []) as $post) {
|
||||
$postId = (int) ($post['knowledge_post_id'] ?? 0);
|
||||
if ($postId > 0) {
|
||||
$postIds[] = $postId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$knowledgePostTitleMap = [];
|
||||
$lookupService = $services['lookupService'] ?? null;
|
||||
if ($lookupService instanceof TaskLookupService) {
|
||||
$languageId = (int) ($GLOBALS['language']['id'] ?? 1);
|
||||
$knowledgePostTitleMap = $lookupService->getKnowledgePostTitleMapByIds($languageId, $postIds);
|
||||
}
|
||||
|
||||
foreach ($methods as &$method) {
|
||||
if ((string) ($method['method_type'] ?? '') !== 'knowledge_read') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) ($method['knowledge_posts'] ?? []) as &$post) {
|
||||
$postId = (int) ($post['knowledge_post_id'] ?? 0);
|
||||
$post['title'] = $knowledgePostTitleMap[$postId] ?? ('Beitrag #' . $postId);
|
||||
}
|
||||
unset($post);
|
||||
}
|
||||
unset($method);
|
||||
|
||||
$responsibles = $services['ruleRepo']->getResponsiblesWithContactData((int) $assignment['task_id']);
|
||||
|
||||
$neededPrefillColumns = [];
|
||||
foreach ($methods as $m) {
|
||||
foreach ((array) ($m['form_fields'] ?? []) as $ff) {
|
||||
$col = trim((string) ($ff['prefill_column'] ?? ''));
|
||||
if ($col !== '' && ContactPrefillColumns::isValid($col)) {
|
||||
$neededPrefillColumns[$col] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$contactData = [];
|
||||
if ($neededPrefillColumns !== []) {
|
||||
try {
|
||||
$contactRow = TaskCertDb::fetchOne(
|
||||
'SELECT * FROM main_contact WHERE id = ? LIMIT 1',
|
||||
'i',
|
||||
[$auth->contactId()]
|
||||
);
|
||||
if (is_array($contactRow)) {
|
||||
$contactData = $contactRow;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$contactData = [];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'error' => null,
|
||||
'assignment' => $assignment,
|
||||
'task' => $task,
|
||||
'methods' => $methods,
|
||||
'submissions' => $submissions,
|
||||
'task_certificates_history' => $services['certificateService']->listUserTaskCertificates(
|
||||
$auth->contactId(),
|
||||
(int) $assignment['task_id']
|
||||
),
|
||||
'responsibles' => $responsibles,
|
||||
'contact_data' => $contactData,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
];
|
||||
}
|
||||
289
module/tasks/assets/css/task_cert.css
Normal file
289
module/tasks/assets/css/task_cert.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* task_cert baseline styles using monolith design tokens with fallbacks */
|
||||
|
||||
.tasks-cert main {
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.admin-task-list main {
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.my-tasks main {
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
.task-cert input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0 0 0px 0px;
|
||||
height: auto;
|
||||
}
|
||||
.task-cert input[type="radio"] {
|
||||
width: auto;
|
||||
margin: 0 0 0px 0px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.task-cert {
|
||||
--tc-font-family: var(--ff-body, var(--ff-primary, sans-serif));
|
||||
--tc-font-size: var(--fs-400, 0.875rem);
|
||||
--tc-font-size-h2: var(--fs-700, 1.125rem);
|
||||
--tc-font-size-h3: var(--fs-650, 1rem);
|
||||
--tc-radius: var(--br, 8px);
|
||||
--tc-border-color: var(--border, #e5e5e5);
|
||||
--tc-bg: var(--blur_elevated, var(--bg-white, #ffffff));
|
||||
--tc-bg-soft: var(--clr-light, #f5f5f5);
|
||||
--tc-text: var(--fc-primary, #272727);
|
||||
--tc-muted: var(--fc-dark-8, #666666);
|
||||
--tc-accent: var(--blue-accent, #1b66b3);
|
||||
--tc-error: var(--error, #cc0000);
|
||||
--tc-error-border: #f0b4b4;
|
||||
--tc-error-bg: #fff1f1;
|
||||
--tc-success: #0f7a0f;
|
||||
--tc-success-border: #a5dca5;
|
||||
--tc-success-bg: #edf9ed;
|
||||
--tc-warning: #9a6400;
|
||||
--tc-warning-border: #f0cf8c;
|
||||
--tc-warning-bg: #fff8e8;
|
||||
|
||||
font-family: var(--tc-font-family);
|
||||
font-size: var(--tc-font-size);
|
||||
color: var(--tc-text);
|
||||
background: var(--tc-bg);
|
||||
}
|
||||
|
||||
.task-cert,
|
||||
.task-cert * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.task-cert h2,
|
||||
.task-cert h3,
|
||||
.task-cert h4 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--tc-text);
|
||||
}
|
||||
|
||||
.task-cert h2 {
|
||||
font-size: var(--tc-font-size-h2);
|
||||
}
|
||||
|
||||
.task-cert h3,
|
||||
.task-cert h4 {
|
||||
font-size: var(--tc-font-size-h3);
|
||||
}
|
||||
|
||||
.task-cert a {
|
||||
color: var(--tc-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-cert a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-toolbar-left,
|
||||
.task-cert .task-cert-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-toolbar-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-links {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-block {
|
||||
border: 1px solid var(--tc-border-color);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-bg-soft);
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-error {
|
||||
border: 1px solid var(--tc-error-border);
|
||||
border-left: 4px solid var(--tc-error);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-error-bg);
|
||||
color: #7a1212;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-success {
|
||||
border: 1px solid var(--tc-success-border);
|
||||
border-left: 4px solid var(--tc-success);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-success-bg);
|
||||
color: #145f14;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-warning {
|
||||
border: 1px solid var(--tc-warning-border);
|
||||
border-left: 4px solid var(--tc-warning);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-warning-bg);
|
||||
color: #7b5200;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert .btn,
|
||||
.task-cert button {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--tc-border-color);
|
||||
border-radius: calc(var(--tc-radius) - 2px);
|
||||
padding: 6px 10px;
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-text);
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-cert .btn:hover,
|
||||
.task-cert button:hover {
|
||||
background: var(--tc-bg-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-cert .btn.task-cert-btn-danger,
|
||||
.task-cert button.task-cert-btn-danger {
|
||||
background: #d93a3a;
|
||||
border-color: #d93a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-cert .btn.task-cert-btn-danger:hover,
|
||||
.task-cert .btn.task-cert-btn-danger:focus-visible,
|
||||
.task-cert button.task-cert-btn-danger:hover,
|
||||
.task-cert button.task-cert-btn-danger:focus-visible {
|
||||
background: #bb2f2f;
|
||||
border-color: #bb2f2f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-cert .btn.task-cert-btn-success,
|
||||
.task-cert button.task-cert-btn-success {
|
||||
background: #14b814;
|
||||
border-color: #14b814;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-cert .btn.task-cert-btn-success:hover,
|
||||
.task-cert .btn.task-cert-btn-success:focus-visible,
|
||||
.task-cert button.task-cert-btn-success:hover,
|
||||
.task-cert button.task-cert-btn-success:focus-visible {
|
||||
background: #119e11;
|
||||
border-color: #119e11;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-cert .btn:disabled,
|
||||
.task-cert button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-cert form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert label {
|
||||
display: inline-block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.task-cert input[type="text"],
|
||||
.task-cert input[type="number"],
|
||||
.task-cert input[type="date"],
|
||||
.task-cert select,
|
||||
.task-cert textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--tc-border-color);
|
||||
border-radius: calc(var(--tc-radius) - 2px);
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-text);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.task-cert textarea {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-table th,
|
||||
.task-cert .task-cert-table td {
|
||||
border: 1px solid var(--tc-border-color);
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-table th {
|
||||
background: var(--tc-bg-soft);
|
||||
}
|
||||
|
||||
.task-cert pre {
|
||||
margin: 0;
|
||||
border: 1px solid var(--tc-border-color);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-text);
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-method {
|
||||
border: 1px solid var(--tc-border-color);
|
||||
border-radius: var(--tc-radius);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
background: var(--tc-bg);
|
||||
}
|
||||
|
||||
.task-cert .task-cert-quiz-question {
|
||||
border-left: 3px solid var(--tc-accent);
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
background: var(--tc-bg-soft);
|
||||
}
|
||||
|
||||
.task-cert .task-cert-hint {
|
||||
margin: 8px 0;
|
||||
color: var(--tc-muted);
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
}
|
||||
1644
module/tasks/assets/css/task_cert_admin.css
Normal file
1644
module/tasks/assets/css/task_cert_admin.css
Normal file
File diff suppressed because it is too large
Load Diff
971
module/tasks/assets/css/task_cert_user.css
Normal file
971
module/tasks/assets/css/task_cert_user.css
Normal file
@@ -0,0 +1,971 @@
|
||||
.task-cert.task-cert-user-overdue-widget {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.task-cert-user-widget-head.widget_header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-head h2 {
|
||||
font-size: var(--fs-500, 0.9375rem);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
background: var(--clr-light, #f5f5f5);
|
||||
padding: 0 8px;
|
||||
font-weight: var(--fw-bold, 700);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-left: 3px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item:hover,
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item:focus-visible {
|
||||
text-decoration: none;
|
||||
border-color: var(--blue-accent, #1b66b3);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-overdue {
|
||||
border-left-color: #d93a3a;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-escalated {
|
||||
border-left-color: #c08a00;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item.is-overdue.is-escalated {
|
||||
border-left-color: #d93a3a;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-item-head strong {
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--fc-dark-8, #666);
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c08a00;
|
||||
background: #fff7e1;
|
||||
color: #815c00;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-overdue-widget .task-cert-user-widget-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-summary-widget {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar {
|
||||
display: block;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-left: 4px solid #14b814;
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
color: var(--fc-dark, #222);
|
||||
padding: 7px 10px;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar:hover,
|
||||
.task-cert.task-cert-user-summary-widget .task-cert-user-summary-bar:focus-visible {
|
||||
border-color: var(--blue-accent, #1b66b3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.task-cert-user-escalation-overlay-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 480px);
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-left: 4px solid #d93a3a;
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
color: var(--fc-dark, #222);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.2);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-panel p {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-escalation-overlay-widget .task-cert-user-escalation-overlay-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── my-tasks: filter bar ── */
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-bar select {
|
||||
min-width: 180px;
|
||||
border: 1px solid var(--tc-border-color, var(--border, #e5e5e5));
|
||||
border-radius: calc(var(--tc-radius, var(--br, 8px)) - 2px);
|
||||
background: var(--tc-bg, var(--blur_elevated, var(--bg-white, #fff)));
|
||||
color: var(--tc-text, var(--fc-primary, #272727));
|
||||
padding: 7px 8px;
|
||||
font-size: var(--fs-400, 0.875rem);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-filter-reset {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-cert-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── my-tasks: category group ── */
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-category-group {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-category-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
border-bottom: 2px solid var(--border, #e5e5e5);
|
||||
background: var(--bg-light);
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-500, 0.9375rem);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-category-head span {
|
||||
color: var(--fc-dark-8, #666);
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card {
|
||||
border-bottom: 1px solid var(--border, #e5e5e5);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card:hover, .task-cert.task-cert-user-my-tasks .task-cert-user-task-card:focus-visible {
|
||||
box-shadow: 0 0 0 1px rgba(27, 102, 179, 0.12);
|
||||
text-decoration: none;
|
||||
border-left: 1px solid var(--blue-accent);
|
||||
}
|
||||
|
||||
a.task-cert-user-task-card.status-completed strong:before {
|
||||
background: #ecffec;
|
||||
border: 1px solid #4bc132 !important;
|
||||
box-shadow: inset 0px 0px 0px 2px #31ac31;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head strong:before {
|
||||
content: '';
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 1px solid black;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head strong {
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-head .task-cert-user-status-badge {
|
||||
justify-self: flex-start;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card-meta {
|
||||
margin-top: auto;
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.status-overdue {
|
||||
border-color: #d93a3a;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-escalated {
|
||||
border-color: #c08a00;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date {
|
||||
border: 1px dashed #d93a3a;
|
||||
background: #fffafa;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date:hover,
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-task-card.is-overdue-date:focus-visible {
|
||||
border-color: #d93a3a;
|
||||
box-shadow: 0 0 0 1px rgba(217, 58, 58, 0.2);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-user-status-badge {
|
||||
padding: 1px 6px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge.status-overdue {
|
||||
border-color: #d93a3a;
|
||||
color: #b52828;
|
||||
background: #ffecec;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge.status-completed {
|
||||
border-color: #14b814;
|
||||
color: #0c7f0c;
|
||||
background: #ecffec;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge.status-approval_pending {
|
||||
border-color: #c08a00;
|
||||
color: #815c00;
|
||||
background: #fff7e1;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge.status-valid {
|
||||
border-color: #14b814;
|
||||
color: #0c7f0c;
|
||||
background: #ecffec;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-user-status-badge.status-expired,
|
||||
.task-cert .task-cert-user-status-badge.status-revoked {
|
||||
border-color: #d93a3a;
|
||||
color: #b52828;
|
||||
background: #ffecec;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-detail-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row h2 {
|
||||
margin: 0;
|
||||
font-size: 1.625rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
background: var(--bg-white, #fff);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link:hover,
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row .task-cert-back-link:focus-visible {
|
||||
border-color: var(--blue-accent, #1b66b3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-status-compact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-status-due {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-status-due.is-overdue {
|
||||
color: #b52828;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-status-steps {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-main {
|
||||
min-width: 0;
|
||||
width: inherit;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-aside {
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-aside .task-cert-block {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-aside .task-cert-block + .task-cert-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* Rich text description content */
|
||||
.task-cert .task-cert-description-content p {
|
||||
margin: 0 0 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content h2 {
|
||||
font-size: 1.25em;
|
||||
margin: 0.8em 0 0.4em;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content h3 {
|
||||
font-size: 1.1em;
|
||||
margin: 0.6em 0 0.3em;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content ul,
|
||||
.task-cert .task-cert-description-content ol {
|
||||
margin: 0.4em 0 0.6em;
|
||||
padding-left: 1.8em;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content li {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.task-cert .task-cert-description-content a {
|
||||
color: var(--tc-primary, #1a73e8);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-step {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-step-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-method-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
background: var(--clr-light, #f5f5f5);
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-done {
|
||||
border-color: #14b814;
|
||||
color: #0c7f0c;
|
||||
background: #ecffec;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-pending {
|
||||
border-color: #c08a00;
|
||||
color: #815c00;
|
||||
background: #fff7e1;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-method-state.state-retry {
|
||||
border-color: #d93a3a;
|
||||
color: #b52828;
|
||||
background: #ffecec;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-posts {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-head strong {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-head a {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-badge {
|
||||
display: inline-block;
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
font-weight: 600;
|
||||
color: #2e7d32;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: var(--br, 8px);
|
||||
padding: 1px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-details {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--bg-white, #fff);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-details > summary::before {
|
||||
content: "\25B6";
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
font-size: 0.75rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-details[open] > summary::before {
|
||||
content: "\25BC";
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-content {
|
||||
border-top: 1px solid var(--border, #e5e5e5);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-checkbox-row small {
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-quiz-question {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-quiz-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-quiz-option {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* ── submission history ── */
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submissions-history {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-card {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-card.status-rejected {
|
||||
border-color: #d93a3a;
|
||||
background: #fff8f8;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-card-date {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
/* ── older submissions (collapsible) ── */
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--fc-dark-8, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions > summary::before {
|
||||
content: "\25B6";
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
font-size: 0.65rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions[open] > summary::before {
|
||||
content: "\25BC";
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-older-submissions-list .task-cert-user-submission-card {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── submission details (collapsible answers) ── */
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-details {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--blue-accent, #1b66b3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-details > summary::before {
|
||||
content: "\25B6";
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
font-size: 0.65rem;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-details[open] > summary::before {
|
||||
content: "\25BC";
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-answers {
|
||||
margin: 6px 0 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border-light, #f0f0f0);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row dt {
|
||||
font-weight: 600;
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-submission-answer-row dd {
|
||||
margin: 0;
|
||||
color: var(--fc-dark, #333);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-cards {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card {
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-left: 4px solid #14b814;
|
||||
border-radius: var(--br, 8px);
|
||||
background: var(--blur_elevated, var(--bg-white, #fff));
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card.status-expired,
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card.status-revoked {
|
||||
border-left-color: #d93a3a;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code strong {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.2;
|
||||
color: var(--fc-dark, #222);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-code small {
|
||||
display: inline-block;
|
||||
max-width: 260px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
color: var(--fc-dark-8, #666);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.25;
|
||||
color: var(--fc-dark-8, #666);
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-certificate-card-meta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-method form > div {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail small {
|
||||
color: var(--fc-dark-8, #666666);
|
||||
}
|
||||
|
||||
/* ── user detail: responsibles ── */
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-responsibles {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-responsible-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-responsible-item strong {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-responsible-item a {
|
||||
font-size: var(--fs-300, 0.8125rem);
|
||||
color: var(--blue-accent, #1b66b3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-my-tasks .task-cert-table td,
|
||||
.task-cert.task-cert-user-detail .task-cert-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-cert.task-cert-user-detail .task-cert-user-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-detail-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-title-row h2 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-step-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-head a {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-read-post-iframe-wrap iframe {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.task-cert.task-cert-user-detail .task-cert-user-quiz-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
219
module/tasks/assets/js/task_cert_admin_actions.js
Normal file
219
module/tasks/assets/js/task_cert_admin_actions.js
Normal file
@@ -0,0 +1,219 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var root = document.querySelector('.task-cert-admin-monitoring');
|
||||
if (!root) { return; }
|
||||
|
||||
var csrfToken = root.getAttribute('data-csrf-token') || '';
|
||||
var resetAssignmentUrl = root.getAttribute('data-reset-assignment-url') || '';
|
||||
var resetCycleUrl = root.getAttribute('data-reset-cycle-url') || '';
|
||||
var approvalUrl = root.getAttribute('data-approval-url') || '';
|
||||
var taskId = parseInt(root.getAttribute('data-task-id') || '0', 10);
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────
|
||||
|
||||
var toastContainer = document.getElementById('task-cert-toast-container');
|
||||
|
||||
function showToast(message, type) {
|
||||
if (!toastContainer) { return; }
|
||||
var cls = type === 'error' ? 'task-cert-error' : 'task-cert-success';
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'task-cert-toast ' + cls;
|
||||
toast.textContent = String(message || '');
|
||||
toast.addEventListener('click', function () {
|
||||
removeToast(toast);
|
||||
});
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger fade-in on next frame
|
||||
requestAnimationFrame(function () {
|
||||
toast.classList.add('is-visible');
|
||||
});
|
||||
|
||||
var autoHideMs = type === 'error' ? 7000 : 4000;
|
||||
setTimeout(function () {
|
||||
removeToast(toast);
|
||||
}, autoHideMs);
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
if (!toast || !toast.parentNode) { return; }
|
||||
toast.classList.remove('is-visible');
|
||||
setTimeout(function () {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// Expose globally so other admin scripts (mark_completed) can reuse
|
||||
window.TaskCertAdminActions = window.TaskCertAdminActions || {};
|
||||
window.TaskCertAdminActions.showToast = showToast;
|
||||
|
||||
// ── Fetch-Helper ──────────────────────────────────────────────
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
return res.json().then(function (data) {
|
||||
return { ok: res.ok, status: res.status, data: data };
|
||||
}).catch(function () {
|
||||
return { ok: false, status: res.status, data: { success: false, error: 'Ungültige Server-Antwort' } };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reloadWithDelay() {
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function setButtonBusy(btn, busy, busyLabel) {
|
||||
if (!btn) { return; }
|
||||
if (busy) {
|
||||
btn._originalLabel = btn.textContent;
|
||||
btn.textContent = busyLabel || 'Wird verarbeitet...';
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
if (btn._originalLabel) { btn.textContent = btn._originalLabel; }
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action: Reset Assignment ──────────────────────────────────
|
||||
|
||||
function handleResetAssignment(btn) {
|
||||
var assignmentId = parseInt(btn.getAttribute('data-assignment-id') || '0', 10);
|
||||
if (!assignmentId || !taskId || !resetAssignmentUrl) { return; }
|
||||
|
||||
if (!window.confirm('Einreichungen und Zertifikat für diesen Zeitraum wirklich zurücksetzen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonBusy(btn, true, 'Wird zurückgesetzt...');
|
||||
|
||||
postJson(resetAssignmentUrl, {
|
||||
csrf_token: csrfToken,
|
||||
task_id: taskId,
|
||||
assignment_id: assignmentId
|
||||
}).then(function (result) {
|
||||
if (result.data && result.data.success) {
|
||||
showToast('Zeitraum wurde zurückgesetzt.', 'success');
|
||||
reloadWithDelay();
|
||||
} else {
|
||||
setButtonBusy(btn, false);
|
||||
showToast((result.data && result.data.error) || 'Fehler beim Zurücksetzen.', 'error');
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setButtonBusy(btn, false);
|
||||
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Action: Reset Cycle ───────────────────────────────────────
|
||||
|
||||
function handleResetCycle(btn) {
|
||||
var btnTaskId = parseInt(btn.getAttribute('data-task-id') || '0', 10) || taskId;
|
||||
if (!btnTaskId || !resetCycleUrl) { return; }
|
||||
|
||||
if (!window.confirm('Wirklich ALLE Einreichungen und Zertifikate im aktuellen Zyklus zurücksetzen? Diese Aktion betrifft alle Mitarbeiter in diesem Zeitraum.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonBusy(btn, true, 'Wird zurückgesetzt...');
|
||||
|
||||
postJson(resetCycleUrl, {
|
||||
csrf_token: csrfToken,
|
||||
task_id: btnTaskId
|
||||
}).then(function (result) {
|
||||
if (result.data && result.data.success) {
|
||||
var stats = (result.data && result.data.stats) || {};
|
||||
var n = parseInt(stats.assignment_count || 0, 10);
|
||||
showToast('Zyklus wurde zurückgesetzt (' + n + ' Zuweisungen).', 'success');
|
||||
reloadWithDelay();
|
||||
} else {
|
||||
setButtonBusy(btn, false);
|
||||
showToast((result.data && result.data.error) || 'Fehler beim Zurücksetzen.', 'error');
|
||||
}
|
||||
}).catch(function (err) {
|
||||
setButtonBusy(btn, false);
|
||||
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Action: Approval ──────────────────────────────────────────
|
||||
|
||||
function handleApproval(btn) {
|
||||
var wrapper = btn.closest('.task-cert-inline-approval-form');
|
||||
if (!wrapper) { return; }
|
||||
|
||||
var submissionId = parseInt(wrapper.getAttribute('data-submission-id') || '0', 10);
|
||||
var decision = btn.getAttribute('data-decision') || '';
|
||||
if (!submissionId || !decision || !approvalUrl) { return; }
|
||||
|
||||
var noteEl = wrapper.querySelector('.task-cert-approval-note');
|
||||
var note = noteEl ? noteEl.value.trim() : '';
|
||||
|
||||
if (decision === 'reject' && !window.confirm('Einreichung wirklich ablehnen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable both buttons in this row
|
||||
var siblings = wrapper.querySelectorAll('.task-cert-btn-approval');
|
||||
for (var i = 0; i < siblings.length; i++) {
|
||||
siblings[i].disabled = true;
|
||||
}
|
||||
setButtonBusy(btn, true, decision === 'approve' ? 'Freigabe...' : 'Ablehnen...');
|
||||
|
||||
postJson(approvalUrl, {
|
||||
csrf_token: csrfToken,
|
||||
submission_id: submissionId,
|
||||
decision: decision,
|
||||
note: note
|
||||
}).then(function (result) {
|
||||
if (result.data && result.data.success) {
|
||||
var msg = decision === 'approve' ? 'Einreichung freigegeben.' : 'Einreichung abgelehnt.';
|
||||
showToast(msg, 'success');
|
||||
reloadWithDelay();
|
||||
} else {
|
||||
for (var j = 0; j < siblings.length; j++) { siblings[j].disabled = false; }
|
||||
setButtonBusy(btn, false);
|
||||
showToast((result.data && result.data.error) || 'Fehler bei der Bearbeitung.', 'error');
|
||||
}
|
||||
}).catch(function (err) {
|
||||
for (var k = 0; k < siblings.length; k++) { siblings[k].disabled = false; }
|
||||
setButtonBusy(btn, false);
|
||||
showToast('Netzwerkfehler: ' + (err && err.message ? err.message : 'Unbekannt'), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event-Delegation ──────────────────────────────────────────
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var resetAssignBtn = e.target.closest('.task-cert-btn-reset-assignment');
|
||||
if (resetAssignBtn) {
|
||||
e.preventDefault();
|
||||
handleResetAssignment(resetAssignBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
var resetCycleBtn = e.target.closest('.task-cert-btn-reset-cycle');
|
||||
if (resetCycleBtn) {
|
||||
e.preventDefault();
|
||||
handleResetCycle(resetCycleBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
var approvalBtn = e.target.closest('.task-cert-btn-approval');
|
||||
if (approvalBtn) {
|
||||
e.preventDefault();
|
||||
handleApproval(approvalBtn);
|
||||
return;
|
||||
}
|
||||
});
|
||||
})();
|
||||
1159
module/tasks/assets/js/task_cert_admin_form_wizard.js
Normal file
1159
module/tasks/assets/js/task_cert_admin_form_wizard.js
Normal file
File diff suppressed because it is too large
Load Diff
292
module/tasks/assets/js/task_cert_admin_mark_completed.js
Normal file
292
module/tasks/assets/js/task_cert_admin_mark_completed.js
Normal file
@@ -0,0 +1,292 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var dialog = document.getElementById('task-cert-mark-completed-dialog');
|
||||
var config = document.getElementById('task-cert-mark-completed-config');
|
||||
if (!dialog || !config) { return; }
|
||||
|
||||
var methodsUrl = config.getAttribute('data-methods-url') || '';
|
||||
var submitUrl = config.getAttribute('data-submit-url') || '';
|
||||
var taskId = parseInt(config.getAttribute('data-task-id') || '0', 10);
|
||||
if (!methodsUrl || !submitUrl || !taskId) { return; }
|
||||
|
||||
var METHOD_LABELS = {
|
||||
knowledge_read: 'Wissensbestätigung',
|
||||
form_submit: 'Formular',
|
||||
quiz: 'Quiz',
|
||||
manual_approval: 'Manuelle Freigabe'
|
||||
};
|
||||
|
||||
function esc(str) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(String(str == null ? '' : str)));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function buildMethodHtml(method) {
|
||||
var id = method.id;
|
||||
var type = method.method_type;
|
||||
var label = METHOD_LABELS[type] || type;
|
||||
var required = method.is_required ? '' : ' <span class="task-cert-hint">(optional)</span>';
|
||||
var fulfilled = method.already_fulfilled;
|
||||
|
||||
var html = '<fieldset class="task-cert-dialog-method' + (fulfilled ? ' is-fulfilled' : '') + '">';
|
||||
html += '<legend>' + esc(label) + required;
|
||||
if (fulfilled) {
|
||||
html += ' <span class="task-cert-admin-fulfilled-badge">Bereits erledigt</span>';
|
||||
}
|
||||
html += '</legend>';
|
||||
|
||||
if (fulfilled) {
|
||||
html += '<p class="task-cert-hint">Wird übersprungen.</p>';
|
||||
} else if (type === 'knowledge_read') {
|
||||
html += '<p class="task-cert-hint">Alle ' + method.knowledge_post_count + ' Beiträge werden automatisch als gelesen markiert.</p>';
|
||||
} else if (type === 'quiz') {
|
||||
html += '<p class="task-cert-hint">Quiz wird automatisch als bestanden gewertet (' + method.quiz_question_count + ' Fragen, volle Punktzahl).</p>';
|
||||
} else if (type === 'manual_approval') {
|
||||
html += '<p class="task-cert-hint">Manuelle Freigabe wird automatisch erteilt.</p>';
|
||||
} else if (type === 'form_submit') {
|
||||
var fields = method.form_fields || [];
|
||||
if (fields.length === 0) {
|
||||
html += '<p class="task-cert-hint">Keine Formularfelder definiert.</p>';
|
||||
} else {
|
||||
html += '<div class="task-cert-dialog-fields">';
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
html += buildFieldHtml(id, fields[i]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</fieldset>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildFieldHtml(methodId, field) {
|
||||
var key = field.field_key;
|
||||
var label = field.label || key;
|
||||
var type = field.field_type;
|
||||
var req = field.is_required ? ' <span class="task-cert-hint">(Pflicht)</span>' : '';
|
||||
var placeholder = esc(field.placeholder || '');
|
||||
var name = 'method__' + methodId + '__' + key;
|
||||
var inputId = 'mc-field-' + methodId + '-' + key;
|
||||
|
||||
var html = '<div class="task-cert-dialog-field">';
|
||||
html += '<label for="' + inputId + '">' + esc(label) + req + '</label>';
|
||||
|
||||
if (type === 'textarea') {
|
||||
html += '<textarea name="' + name + '" id="' + inputId + '" rows="3" placeholder="' + placeholder + '"></textarea>';
|
||||
} else if (type === 'select' && field.options.length > 0) {
|
||||
html += '<select name="' + name + '" id="' + inputId + '">';
|
||||
html += '<option value="">-- Bitte wählen --</option>';
|
||||
for (var i = 0; i < field.options.length; i++) {
|
||||
html += '<option value="' + esc(field.options[i].value) + '">' + esc(field.options[i].label) + '</option>';
|
||||
}
|
||||
html += '</select>';
|
||||
} else if (type === 'checkbox' && field.options.length > 0) {
|
||||
html += '<div class="task-cert-dialog-checkbox-group">';
|
||||
for (var j = 0; j < field.options.length; j++) {
|
||||
html += '<label class="task-cert-dialog-check-label"><input type="checkbox" name="' + name + '[]" value="' + esc(field.options[j].value) + '"> ' + esc(field.options[j].label) + '</label>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else if (type === 'radio' && field.options.length > 0) {
|
||||
html += '<div class="task-cert-dialog-radio-group">';
|
||||
for (var k = 0; k < field.options.length; k++) {
|
||||
html += '<label class="task-cert-dialog-check-label"><input type="radio" name="' + name + '" value="' + esc(field.options[k].value) + '"> ' + esc(field.options[k].label) + '</label>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
var inputType = type === 'number' ? 'number' : (type === 'date' ? 'date' : 'text');
|
||||
html += '<input type="' + inputType + '" name="' + name + '" id="' + inputId + '" placeholder="' + placeholder + '">';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildDialogContent(data) {
|
||||
var html = '<div class="task-cert-dialog-inner">';
|
||||
|
||||
html += '<div class="task-cert-dialog-header">';
|
||||
html += '<h3>Als erledigt markieren</h3>';
|
||||
html += '<button type="button" class="task-cert-dialog-close" aria-label="Schließen">×</button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="task-cert-dialog-info">';
|
||||
html += '<table class="task-cert-info-table">';
|
||||
html += '<tr><th>Aufgabe</th><td>' + esc(data.task_title) + '</td></tr>';
|
||||
html += '<tr><th>Mitarbeiter</th><td>' + esc(data.contact_name);
|
||||
if (data.contact_email) { html += ' <span class="task-cert-hint">(' + esc(data.contact_email) + ')</span>'; }
|
||||
html += '</td></tr>';
|
||||
html += '<tr><th>Zyklus</th><td>' + esc(data.cycle_key) + '</td></tr>';
|
||||
html += '</table>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<form id="task-cert-mark-completed-form">';
|
||||
html += '<input type="hidden" name="csrf_token" value="' + esc(data.csrf_token) + '">';
|
||||
|
||||
if (data.methods && data.methods.length > 0) {
|
||||
html += '<div class="task-cert-dialog-methods">';
|
||||
html += '<h4>Methoden</h4>';
|
||||
html += '<p class="task-cert-hint">Einreichungen werden automatisch erstellt. Bei Formularen können Sie optional Antworten hinterlegen.</p>';
|
||||
for (var i = 0; i < data.methods.length; i++) {
|
||||
html += buildMethodHtml(data.methods[i]);
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="task-cert-dialog-note">';
|
||||
html += '<h4>Admin-Notiz</h4>';
|
||||
html += '<textarea name="admin_note" rows="2" placeholder="Optionale Begründung (wird im Änderungsprotokoll gespeichert)"></textarea>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="task-cert-dialog-actions">';
|
||||
html += '<button type="button" class="btn task-cert-dialog-cancel">Abbrechen</button>';
|
||||
html += '<button type="submit" class="btn task-cert-btn-success">Als erledigt markieren</button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div id="task-cert-dialog-error" class="task-cert-error" hidden></div>';
|
||||
|
||||
html += '</form>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function collectFormData(data) {
|
||||
var form = document.getElementById('task-cert-mark-completed-form');
|
||||
if (!form) { return null; }
|
||||
|
||||
var adminNote = (form.querySelector('[name="admin_note"]') || {}).value || '';
|
||||
var csrfToken = (form.querySelector('[name="csrf_token"]') || {}).value || '';
|
||||
|
||||
var methodPayloads = {};
|
||||
var methods = data.methods || [];
|
||||
for (var i = 0; i < methods.length; i++) {
|
||||
var m = methods[i];
|
||||
if (m.already_fulfilled || m.method_type !== 'form_submit') { continue; }
|
||||
|
||||
var fields = m.form_fields || [];
|
||||
var payload = {};
|
||||
for (var j = 0; j < fields.length; j++) {
|
||||
var key = fields[j].field_key;
|
||||
var name = 'method__' + m.id + '__' + key;
|
||||
|
||||
if (fields[j].field_type === 'checkbox') {
|
||||
var checked = form.querySelectorAll('[name="' + name + '[]"]:checked');
|
||||
var vals = [];
|
||||
for (var k = 0; k < checked.length; k++) { vals.push(checked[k].value); }
|
||||
payload[key] = vals;
|
||||
} else {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
payload[key] = el ? el.value : '';
|
||||
}
|
||||
}
|
||||
methodPayloads[m.id] = payload;
|
||||
}
|
||||
|
||||
return {
|
||||
csrf_token: csrfToken,
|
||||
task_id: taskId,
|
||||
assignment_id: data.assignment_id,
|
||||
admin_note: adminNote,
|
||||
method: methodPayloads
|
||||
};
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var errEl = document.getElementById('task-cert-dialog-error');
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(on) {
|
||||
var btn = dialog.querySelector('[type="submit"]');
|
||||
if (btn) {
|
||||
btn.disabled = on;
|
||||
btn.textContent = on ? 'Wird gespeichert...' : 'Als erledigt markieren';
|
||||
}
|
||||
}
|
||||
|
||||
var currentData = null;
|
||||
|
||||
function openDialog(assignmentId) {
|
||||
dialog.innerHTML = '<div class="task-cert-dialog-inner"><p>Daten werden geladen...</p></div>';
|
||||
dialog.showModal();
|
||||
|
||||
fetch(methodsUrl + '?assignment_id=' + assignmentId, { credentials: 'same-origin' })
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
dialog.innerHTML = '<div class="task-cert-dialog-inner"><div class="task-cert-error">' + esc(data.error) + '</div><div class="task-cert-dialog-actions"><button type="button" class="btn task-cert-dialog-cancel">Schließen</button></div></div>';
|
||||
return;
|
||||
}
|
||||
currentData = data;
|
||||
dialog.innerHTML = buildDialogContent(data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
dialog.innerHTML = '<div class="task-cert-dialog-inner"><div class="task-cert-error">Fehler: ' + esc(err.message) + '</div><div class="task-cert-dialog-actions"><button type="button" class="btn task-cert-dialog-cancel">Schließen</button></div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function submitDialog() {
|
||||
if (!currentData) { return; }
|
||||
|
||||
var body = collectFormData(currentData);
|
||||
if (!body) { return; }
|
||||
|
||||
setLoading(true);
|
||||
|
||||
fetch(submitUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
if (result.success) {
|
||||
dialog.close();
|
||||
if (window.TaskCertAdminActions && window.TaskCertAdminActions.showToast) {
|
||||
window.TaskCertAdminActions.showToast('Zuweisung als erledigt markiert.', 'success');
|
||||
}
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
} else {
|
||||
setLoading(false);
|
||||
showError(result.error || 'Unbekannter Fehler');
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
setLoading(false);
|
||||
showError('Netzwerkfehler: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Event delegation
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.task-cert-btn-mark-completed');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
var aid = parseInt(btn.getAttribute('data-assignment-id') || '0', 10);
|
||||
if (aid > 0) { openDialog(aid); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.closest('.task-cert-dialog-close') || e.target.closest('.task-cert-dialog-cancel')) {
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
submitDialog();
|
||||
});
|
||||
|
||||
dialog.addEventListener('click', function (e) {
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
})();
|
||||
603
module/tasks/assets/js/task_cert_admin_overview_grid.js
Normal file
603
module/tasks/assets/js/task_cert_admin_overview_grid.js
Normal file
@@ -0,0 +1,603 @@
|
||||
// ── CSV-Export Hilfsfunktionen (global) ──────────────────────────
|
||||
var TaskCertCsvExport = (function () {
|
||||
'use strict';
|
||||
|
||||
function escapeCsvCell(val) {
|
||||
var str = String(val == null ? '' : val);
|
||||
if (str.indexOf('"') !== -1 || str.indexOf(';') !== -1 || str.indexOf('\n') !== -1) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function buildCsv(headers, rows) {
|
||||
var lines = [headers.map(escapeCsvCell).join(';')];
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
lines.push(rows[i].map(escapeCsvCell).join(';'));
|
||||
}
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function download(csvString, filename) {
|
||||
var bom = '\uFEFF';
|
||||
var blob = new Blob([bom + csvString], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return { escapeCsvCell: escapeCsvCell, buildCsv: buildCsv, download: download };
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Übersicht-Grid
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var container = document.getElementById('task-cert-overview-grid');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var dataEl = document.getElementById('task-cert-overview-grid-data');
|
||||
if (!dataEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var allRows = [];
|
||||
try {
|
||||
allRows = JSON.parse(dataEl.textContent || '[]');
|
||||
} catch (e) {
|
||||
container.textContent = 'Fehler beim Laden der Tabellendaten.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.gridjs || !window.gridjs.Grid) {
|
||||
container.textContent = 'Grid.js konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = gridjs.html;
|
||||
var statusFilterEl = document.getElementById('task-cert-overview-status-filter');
|
||||
var searchInputEl = document.getElementById('task-cert-overview-search');
|
||||
var initialStatusFilter = container.getAttribute('data-initial-status-filter') || '';
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) { return ''; }
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(String(str)));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function stripHtmlTags(val) {
|
||||
if (!val) { return ''; }
|
||||
if (typeof val === 'object' && val.props && val.props.content) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = val.props.content;
|
||||
return (tmp.textContent || tmp.innerText || '').trim();
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatDateDisplay(isoValue) {
|
||||
if (!isoValue) { return '-'; }
|
||||
try {
|
||||
var d = new Date(isoValue);
|
||||
if (isNaN(d.getTime())) { return isoValue; }
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
var month = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var year = d.getFullYear();
|
||||
var hours = ('0' + d.getHours()).slice(-2);
|
||||
var minutes = ('0' + d.getMinutes()).slice(-2);
|
||||
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
|
||||
} catch (e) {
|
||||
return isoValue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sort-Comparators ─────────────────────────────────────────
|
||||
|
||||
function compareHtmlText(a, b) {
|
||||
var ta = stripHtmlTags(a);
|
||||
var tb = stripHtmlTags(b);
|
||||
return ta.localeCompare(tb, 'de');
|
||||
}
|
||||
|
||||
function compareDateValues(a, b) {
|
||||
var sa = (a && a._sort) || '';
|
||||
var sb = (b && b._sort) || '';
|
||||
if (sa === sb) { return 0; }
|
||||
if (sa === '') { return 1; }
|
||||
if (sb === '') { return -1; }
|
||||
return sa < sb ? -1 : 1;
|
||||
}
|
||||
|
||||
function compareNumeric(a, b) {
|
||||
var ta = stripHtmlTags(a);
|
||||
var tb = stripHtmlTags(b);
|
||||
var na = ta === '-' || ta === '' ? -1 : parseFloat(ta) || 0;
|
||||
var nb = tb === '-' || tb === '' ? -1 : parseFloat(tb) || 0;
|
||||
return na - nb;
|
||||
}
|
||||
|
||||
function compareStrings(a, b) {
|
||||
var sa = String(a || '');
|
||||
var sb = String(b || '');
|
||||
if (sa === '-') { sa = ''; }
|
||||
if (sb === '-') { sb = ''; }
|
||||
return sa.localeCompare(sb, 'de');
|
||||
}
|
||||
|
||||
// ── Zell-Helfer ──────────────────────────────────────────────
|
||||
|
||||
function dateCell(isoValue) {
|
||||
return {
|
||||
_sort: isoValue || '',
|
||||
_display: formatDateDisplay(isoValue)
|
||||
};
|
||||
}
|
||||
|
||||
function dateCellFormatter(cell) {
|
||||
if (cell && typeof cell === 'object' && cell._display !== undefined) {
|
||||
return cell._display;
|
||||
}
|
||||
return cell || '-';
|
||||
}
|
||||
|
||||
// ── Suchtext-Cache ───────────────────────────────────────────
|
||||
|
||||
function buildSearchText(row) {
|
||||
return [
|
||||
row.contact_name || '',
|
||||
row.contact_email || '',
|
||||
row.status_label || '',
|
||||
row.cycle_key || '',
|
||||
row.certificate_label || '',
|
||||
row.certificate_code || '',
|
||||
formatDateDisplay(row.assigned_at_sort),
|
||||
formatDateDisplay(row.due_at_sort),
|
||||
formatDateDisplay(row.completed_at_sort),
|
||||
formatDateDisplay(row.overdue_at_sort),
|
||||
formatDateDisplay(row.last_activity_sort),
|
||||
formatDateDisplay(row.valid_until_sort)
|
||||
].join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
allRows.forEach(function (row) {
|
||||
row._searchText = buildSearchText(row);
|
||||
});
|
||||
|
||||
// ── Grid-Daten bauen ─────────────────────────────────────────
|
||||
|
||||
function buildGridData(rows) {
|
||||
return rows.map(function (row) {
|
||||
var nameHtml = '<span>' + escapeHtml(row.contact_name || '-') + '</span>';
|
||||
if (row.contact_email) {
|
||||
nameHtml += '<br><small>' + escapeHtml(row.contact_email) + '</small>';
|
||||
}
|
||||
|
||||
var statusText = row.status_label || '-';
|
||||
|
||||
var escalationHtml = '-';
|
||||
if (row.grace_badge_html) {
|
||||
escalationHtml = row.grace_badge_html;
|
||||
} else if (row.escalation_level !== null && row.escalation_level !== undefined && row.escalation_level !== '') {
|
||||
escalationHtml = String(row.escalation_level);
|
||||
}
|
||||
|
||||
var certHtml = '-';
|
||||
if (row.certificate_label) {
|
||||
certHtml = escapeHtml(row.certificate_label);
|
||||
if (row.certificate_code) {
|
||||
certHtml += '<br><small>' + escapeHtml(row.certificate_code) + '</small>';
|
||||
}
|
||||
}
|
||||
|
||||
var actionHtml = row._row_html_action || '-';
|
||||
|
||||
return [
|
||||
html(nameHtml),
|
||||
statusText,
|
||||
dateCell(row.assigned_at_sort),
|
||||
dateCell(row.due_at_sort),
|
||||
dateCell(row.completed_at_sort),
|
||||
dateCell(row.overdue_at_sort),
|
||||
html(escalationHtml),
|
||||
dateCell(row.last_activity_sort),
|
||||
html(certHtml),
|
||||
dateCell(row.valid_until_sort),
|
||||
row.cycle_key || '-',
|
||||
html(actionHtml)
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter-Logik ─────────────────────────────────────────────
|
||||
|
||||
var currentStatusFilter = initialStatusFilter;
|
||||
var currentSearchTerm = '';
|
||||
|
||||
function getFilteredRows() {
|
||||
var rows = allRows;
|
||||
|
||||
if (currentStatusFilter) {
|
||||
rows = rows.filter(function (row) {
|
||||
return row.status === currentStatusFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSearchTerm) {
|
||||
var term = currentSearchTerm.toLowerCase();
|
||||
rows = rows.filter(function (row) {
|
||||
return row._searchText.indexOf(term) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
grid.updateConfig({
|
||||
data: buildGridData(getFilteredRows())
|
||||
}).forceRender();
|
||||
}
|
||||
|
||||
// ── Grid initialisieren ──────────────────────────────────────
|
||||
|
||||
var grid = new gridjs.Grid({
|
||||
columns: [
|
||||
{ name: 'Mitarbeiter', width: '180px', sort: { compare: compareHtmlText } },
|
||||
{ name: 'Status', width: '130px', sort: { compare: compareStrings } },
|
||||
{ name: 'Zugewiesen', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Fällig', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Abgeschlossen', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Überfällig seit', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Eskalation', width: '90px', sort: { compare: compareNumeric } },
|
||||
{ name: 'Letzte Akt.', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Zertifikat', width: '120px', sort: { compare: compareHtmlText } },
|
||||
{ name: 'Gültig bis', width: '130px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Zyklus', width: '90px', sort: { compare: compareStrings } },
|
||||
{ name: 'Aktion', width: '130px', sort: false }
|
||||
],
|
||||
data: buildGridData(getFilteredRows()),
|
||||
search: false,
|
||||
sort: {
|
||||
multiColumn: false
|
||||
},
|
||||
pagination: {
|
||||
enabled: true,
|
||||
limit: 25
|
||||
},
|
||||
language: {
|
||||
pagination: {
|
||||
previous: 'Zurück',
|
||||
next: 'Weiter',
|
||||
showing: 'Zeige',
|
||||
of: 'von',
|
||||
to: 'bis',
|
||||
results: 'Einträge'
|
||||
},
|
||||
noRecordsFound: 'Keine Einträge gefunden.',
|
||||
loading: 'Laden...',
|
||||
error: 'Fehler beim Laden der Daten.'
|
||||
},
|
||||
className: {
|
||||
table: 'task-cert-gridjs-table',
|
||||
th: 'task-cert-gridjs-th',
|
||||
td: 'task-cert-gridjs-td'
|
||||
},
|
||||
autoWidth: false
|
||||
});
|
||||
|
||||
grid.render(container);
|
||||
|
||||
// ── Event-Handler ────────────────────────────────────────────
|
||||
|
||||
if (statusFilterEl) {
|
||||
if (initialStatusFilter) {
|
||||
statusFilterEl.value = initialStatusFilter;
|
||||
}
|
||||
statusFilterEl.addEventListener('change', function () {
|
||||
currentStatusFilter = this.value;
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInputEl) {
|
||||
var searchTimeout = null;
|
||||
searchInputEl.addEventListener('input', function () {
|
||||
var val = this.value.trim();
|
||||
if (searchTimeout) { clearTimeout(searchTimeout); }
|
||||
searchTimeout = setTimeout(function () {
|
||||
currentSearchTerm = val;
|
||||
applyFilters();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// CSV-Export Übersicht
|
||||
var exportBtn = document.getElementById('task-cert-overview-export');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function () {
|
||||
var rows = getFilteredRows();
|
||||
var headers = ['Mitarbeiter', 'E-Mail', 'Status', 'Zugewiesen', 'Fällig', 'Abgeschlossen',
|
||||
'Überfällig seit', 'Eskalation', 'Letzte Aktivität', 'Zertifikat', 'Gültig bis', 'Zyklus'];
|
||||
var csvRows = rows.map(function (row) {
|
||||
return [
|
||||
row.contact_name || '',
|
||||
row.contact_email || '',
|
||||
row.status_label || '',
|
||||
formatDateDisplay(row.assigned_at_sort),
|
||||
formatDateDisplay(row.due_at_sort),
|
||||
formatDateDisplay(row.completed_at_sort),
|
||||
formatDateDisplay(row.overdue_at_sort),
|
||||
row.escalation_level != null ? row.escalation_level : '',
|
||||
formatDateDisplay(row.last_activity_sort),
|
||||
row.certificate_label || '',
|
||||
formatDateDisplay(row.valid_until_sort),
|
||||
row.cycle_key || ''
|
||||
];
|
||||
});
|
||||
var csv = TaskCertCsvExport.buildCsv(headers, csvRows);
|
||||
TaskCertCsvExport.download(csv, 'uebersicht-export.csv');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Antworten-Grid
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var container = document.getElementById('task-cert-answers-grid');
|
||||
if (!container) { return; }
|
||||
|
||||
var dataEl = document.getElementById('task-cert-answers-grid-data');
|
||||
if (!dataEl) { return; }
|
||||
|
||||
var allRows = [];
|
||||
try {
|
||||
allRows = JSON.parse(dataEl.textContent || '[]');
|
||||
} catch (e) {
|
||||
container.textContent = 'Fehler beim Laden der Tabellendaten.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.gridjs || !window.gridjs.Grid) {
|
||||
container.textContent = 'Grid.js konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = gridjs.html;
|
||||
var searchInputEl = document.getElementById('task-cert-answers-search');
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) { return ''; }
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(String(str)));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDateDisplay(isoValue) {
|
||||
if (!isoValue) { return '-'; }
|
||||
try {
|
||||
var d = new Date(isoValue);
|
||||
if (isNaN(d.getTime())) { return isoValue; }
|
||||
var day = ('0' + d.getDate()).slice(-2);
|
||||
var month = ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
var year = d.getFullYear();
|
||||
var hours = ('0' + d.getHours()).slice(-2);
|
||||
var minutes = ('0' + d.getMinutes()).slice(-2);
|
||||
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
|
||||
} catch (e) {
|
||||
return isoValue;
|
||||
}
|
||||
}
|
||||
|
||||
function compareStrings(a, b) {
|
||||
var sa = String(a || '');
|
||||
var sb = String(b || '');
|
||||
if (sa === '-') { sa = ''; }
|
||||
if (sb === '-') { sb = ''; }
|
||||
return sa.localeCompare(sb, 'de');
|
||||
}
|
||||
|
||||
function compareDateValues(a, b) {
|
||||
var sa = (a && a._sort) || '';
|
||||
var sb = (b && b._sort) || '';
|
||||
if (sa === sb) { return 0; }
|
||||
if (sa === '') { return 1; }
|
||||
if (sb === '') { return -1; }
|
||||
return sa < sb ? -1 : 1;
|
||||
}
|
||||
|
||||
function compareHtmlText(a, b) {
|
||||
var ta = stripHtmlTags(a);
|
||||
var tb = stripHtmlTags(b);
|
||||
return ta.localeCompare(tb, 'de');
|
||||
}
|
||||
|
||||
function compareNumeric(a, b) {
|
||||
var ta = stripHtmlTags(a);
|
||||
var tb = stripHtmlTags(b);
|
||||
var na = ta === '-' || ta === '' ? -1 : parseFloat(ta) || 0;
|
||||
var nb = tb === '-' || tb === '' ? -1 : parseFloat(tb) || 0;
|
||||
return na - nb;
|
||||
}
|
||||
|
||||
function stripHtmlTags(val) {
|
||||
if (!val) { return ''; }
|
||||
if (typeof val === 'object' && val.props && val.props.content) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = val.props.content;
|
||||
return (tmp.textContent || tmp.innerText || '').trim();
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function dateCell(isoValue) {
|
||||
return { _sort: isoValue || '', _display: formatDateDisplay(isoValue) };
|
||||
}
|
||||
|
||||
function dateCellFormatter(cell) {
|
||||
if (cell && typeof cell === 'object' && cell._display !== undefined) {
|
||||
return cell._display;
|
||||
}
|
||||
return cell || '-';
|
||||
}
|
||||
|
||||
// Suchtext-Cache
|
||||
allRows.forEach(function (row) {
|
||||
row._searchText = [
|
||||
row.contact_name || '',
|
||||
row.contact_email || '',
|
||||
row.method_label || '',
|
||||
row.status_label || '',
|
||||
row.score || '',
|
||||
formatDateDisplay(row.submitted_at_sort)
|
||||
].join(' ').toLowerCase();
|
||||
});
|
||||
|
||||
function buildGridData(rows) {
|
||||
return rows.map(function (row) {
|
||||
var nameHtml = '<span>' + escapeHtml(row.contact_name || '-') + '</span>';
|
||||
if (row.contact_email) {
|
||||
nameHtml += '<br><small>' + escapeHtml(row.contact_email) + '</small>';
|
||||
}
|
||||
|
||||
var detailHtml = row.detail_html || '-';
|
||||
if (detailHtml !== '-') {
|
||||
detailHtml = '<details><summary>Anzeigen</summary>' + detailHtml + '</details>';
|
||||
}
|
||||
|
||||
return [
|
||||
html(nameHtml),
|
||||
row.method_label || '-',
|
||||
row.status_label || '-',
|
||||
dateCell(row.submitted_at_sort),
|
||||
row.score || '-',
|
||||
html(detailHtml)
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
var currentSearchTerm = '';
|
||||
|
||||
function getFilteredRows() {
|
||||
var rows = allRows;
|
||||
if (currentSearchTerm) {
|
||||
var term = currentSearchTerm.toLowerCase();
|
||||
rows = rows.filter(function (row) {
|
||||
return row._searchText.indexOf(term) !== -1;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
answersGrid.updateConfig({
|
||||
data: buildGridData(getFilteredRows())
|
||||
}).forceRender();
|
||||
}
|
||||
|
||||
var answersGrid = new gridjs.Grid({
|
||||
columns: [
|
||||
{ name: 'Mitarbeiter', width: '180px', sort: { compare: compareHtmlText } },
|
||||
{ name: 'Methode', width: '130px', sort: { compare: compareStrings } },
|
||||
{ name: 'Status', width: '130px', sort: { compare: compareStrings } },
|
||||
{ name: 'Eingereicht', width: '140px', formatter: dateCellFormatter, sort: { compare: compareDateValues } },
|
||||
{ name: 'Punkte', width: '80px', sort: { compare: compareStrings } },
|
||||
{ name: 'Details', width: '250px', sort: false }
|
||||
],
|
||||
data: buildGridData(getFilteredRows()),
|
||||
search: false,
|
||||
sort: { multiColumn: false },
|
||||
pagination: { enabled: true, limit: 25 },
|
||||
language: {
|
||||
pagination: {
|
||||
previous: 'Zurück',
|
||||
next: 'Weiter',
|
||||
showing: 'Zeige',
|
||||
of: 'von',
|
||||
to: 'bis',
|
||||
results: 'Einträge'
|
||||
},
|
||||
noRecordsFound: 'Keine Einträge gefunden.',
|
||||
loading: 'Laden...',
|
||||
error: 'Fehler beim Laden der Daten.'
|
||||
},
|
||||
className: {
|
||||
table: 'task-cert-gridjs-table',
|
||||
th: 'task-cert-gridjs-th',
|
||||
td: 'task-cert-gridjs-td'
|
||||
},
|
||||
autoWidth: false
|
||||
});
|
||||
|
||||
answersGrid.render(container);
|
||||
|
||||
if (searchInputEl) {
|
||||
var searchTimeout = null;
|
||||
searchInputEl.addEventListener('input', function () {
|
||||
var val = this.value.trim();
|
||||
if (searchTimeout) { clearTimeout(searchTimeout); }
|
||||
searchTimeout = setTimeout(function () {
|
||||
currentSearchTerm = val;
|
||||
applyFilters();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// CSV-Export Antworten (mit Antwort-Details als einzelne Spalten)
|
||||
var exportBtn = document.getElementById('task-cert-answers-export');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function () {
|
||||
var rows = getFilteredRows();
|
||||
|
||||
// Alle einzigartigen Antwort-Labels sammeln (Reihenfolge beibehalten)
|
||||
var fieldLabels = [];
|
||||
var fieldLabelSet = {};
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var fields = rows[i].answer_fields || {};
|
||||
for (var label in fields) {
|
||||
if (fields.hasOwnProperty(label) && !fieldLabelSet[label]) {
|
||||
fieldLabelSet[label] = true;
|
||||
fieldLabels.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var headers = ['Mitarbeiter', 'E-Mail', 'Methode', 'Status', 'Eingereicht', 'Punkte'];
|
||||
headers = headers.concat(fieldLabels);
|
||||
|
||||
var csvRows = rows.map(function (row) {
|
||||
var base = [
|
||||
row.contact_name || '',
|
||||
row.contact_email || '',
|
||||
row.method_label || '',
|
||||
row.status_label || '',
|
||||
formatDateDisplay(row.submitted_at_sort),
|
||||
row.score || ''
|
||||
];
|
||||
var fields = row.answer_fields || {};
|
||||
for (var j = 0; j < fieldLabels.length; j++) {
|
||||
base.push(fields[fieldLabels[j]] || '');
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
var csv = TaskCertCsvExport.buildCsv(headers, csvRows);
|
||||
TaskCertCsvExport.download(csv, 'antworten-export.csv');
|
||||
});
|
||||
}
|
||||
})();
|
||||
122
module/tasks/assets/js/task_cert_escalation_overlay_widget.js
Normal file
122
module/tasks/assets/js/task_cert_escalation_overlay_widget.js
Normal file
@@ -0,0 +1,122 @@
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
function toArray(nodeList) {
|
||||
return Array.prototype.slice.call(nodeList || []);
|
||||
}
|
||||
|
||||
function dayTokenLocal() {
|
||||
var now = new Date();
|
||||
var year = String(now.getFullYear());
|
||||
var month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
var day = String(now.getDate()).padStart(2, '0');
|
||||
return year + '-' + month + '-' + day;
|
||||
}
|
||||
|
||||
function storageKey(viewerId, dayToken) {
|
||||
return 'taskCertEscOverlayAck:v1:' + viewerId + ':' + dayToken;
|
||||
}
|
||||
|
||||
function hasAck(key) {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === '1';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setAck(key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, '1');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAndCleanup(root, overlay) {
|
||||
if (overlay) {
|
||||
overlay.hidden = true;
|
||||
}
|
||||
document.body.classList.remove('task-cert-user-escalation-overlay-open');
|
||||
if (root && root.parentNode) {
|
||||
root.parentNode.removeChild(root);
|
||||
}
|
||||
}
|
||||
|
||||
function initEscalationOverlay(root) {
|
||||
if (!(root instanceof window.HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (root.dataset.overlayInit === '1') {
|
||||
return;
|
||||
}
|
||||
root.dataset.overlayInit = '1';
|
||||
|
||||
var overlay = root.querySelector('.task-cert-user-escalation-overlay');
|
||||
if (!(overlay instanceof window.HTMLElement)) {
|
||||
closeAndCleanup(root, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var viewerId = parseInt(root.getAttribute('data-viewer-id') || '0', 10);
|
||||
if (!viewerId || viewerId <= 0) {
|
||||
closeAndCleanup(root, overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
var key = storageKey(viewerId, dayTokenLocal());
|
||||
if (hasAck(key)) {
|
||||
closeAndCleanup(root, overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
var myTasksUrl = root.getAttribute('data-my-tasks-url') || '';
|
||||
var myTasksLink = root.querySelector('[data-overlay-my-tasks]');
|
||||
var ackButton = root.querySelector('[data-overlay-ack]');
|
||||
|
||||
if (myTasksLink instanceof window.HTMLAnchorElement && myTasksUrl !== '') {
|
||||
myTasksLink.href = myTasksUrl;
|
||||
}
|
||||
|
||||
overlay.hidden = false;
|
||||
document.body.classList.add('task-cert-user-escalation-overlay-open');
|
||||
|
||||
if (ackButton instanceof window.HTMLButtonElement) {
|
||||
ackButton.focus();
|
||||
ackButton.addEventListener('click', function () {
|
||||
setAck(key);
|
||||
closeAndCleanup(root, overlay);
|
||||
});
|
||||
}
|
||||
|
||||
if (myTasksLink instanceof window.HTMLAnchorElement) {
|
||||
myTasksLink.addEventListener('click', function () {
|
||||
setAck(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function autoInit() {
|
||||
var roots = toArray(document.querySelectorAll('[data-task-cert-escalation-overlay]'));
|
||||
if (roots.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = roots[0];
|
||||
for (var i = 1; i < roots.length; i += 1) {
|
||||
if (roots[i] && roots[i].parentNode) {
|
||||
roots[i].parentNode.removeChild(roots[i]);
|
||||
}
|
||||
}
|
||||
|
||||
initEscalationOverlay(primary);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', autoInit);
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
})(window, document);
|
||||
|
||||
78
module/tasks/assets/js/task_cert_quill_editor.js
Normal file
78
module/tasks/assets/js/task_cert_quill_editor.js
Normal file
@@ -0,0 +1,78 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var textarea = document.getElementById('description');
|
||||
if (!textarea) return;
|
||||
|
||||
var form = textarea.closest('form');
|
||||
if (!form) return;
|
||||
|
||||
// Create editor container
|
||||
var editorContainer = document.createElement('div');
|
||||
editorContainer.id = 'description-editor';
|
||||
textarea.parentNode.insertBefore(editorContainer, textarea.nextSibling);
|
||||
|
||||
// Hide original textarea
|
||||
textarea.style.display = 'none';
|
||||
|
||||
// Determine initial content
|
||||
var initialContent = textarea.value || '';
|
||||
var isHtml = /<[a-z][\s\S]*>/i.test(initialContent);
|
||||
|
||||
// Better-Table-Modul registrieren (idempotent)
|
||||
if (typeof quillBetterTable !== 'undefined') {
|
||||
Quill.register({ 'modules/better-table': quillBetterTable }, true);
|
||||
}
|
||||
|
||||
// Initialize Quill auf Goldstandard-Compact (kein Image/Video)
|
||||
var quill = new Quill('#description-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Beschreibung eingeben...',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: window.QuillToolbarPresets.compact(),
|
||||
handlers: {
|
||||
table: function () {
|
||||
var tableModule = this.quill.getModule('better-table');
|
||||
if (tableModule) tableModule.insertTable(3, 3);
|
||||
}
|
||||
}
|
||||
},
|
||||
table: false,
|
||||
'better-table': window.QuillToolbarPresets.betterTableConfig(),
|
||||
keyboard: (typeof quillBetterTable !== 'undefined')
|
||||
? { bindings: quillBetterTable.keyboardBindings }
|
||||
: undefined,
|
||||
imageResize: true
|
||||
}
|
||||
});
|
||||
|
||||
// Load existing content via dangerouslyPasteHTML statt innerHTML —
|
||||
// so behält Quill 2.x auch alte Listen-Strukturen korrekt.
|
||||
if (initialContent !== '') {
|
||||
if (isHtml) {
|
||||
quill.clipboard.dangerouslyPasteHTML(0, initialContent, 'silent');
|
||||
} else {
|
||||
// Plain text: convert newlines to paragraphs
|
||||
var paragraphs = initialContent.split(/\n\n+/);
|
||||
var html = paragraphs.map(function (p) {
|
||||
var escaped = p.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
escaped = escaped.replace(/\n/g, '<br>');
|
||||
return '<p>' + escaped + '</p>';
|
||||
}).join('');
|
||||
quill.clipboard.dangerouslyPasteHTML(0, html, 'silent');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Quill content to textarea before form submit
|
||||
form.addEventListener('submit', function () {
|
||||
var html = quill.root.innerHTML || '';
|
||||
|
||||
// Treat Quill empty state as empty string
|
||||
if (html === '<p><br></p>' || html.replace(/<p>\s*<br\s*\/?>\s*<\/p>/g, '').trim() === '') {
|
||||
textarea.value = '';
|
||||
} else {
|
||||
textarea.value = html;
|
||||
}
|
||||
});
|
||||
})();
|
||||
139
module/tasks/assets/js/task_cert_tabs.js
Normal file
139
module/tasks/assets/js/task_cert_tabs.js
Normal file
@@ -0,0 +1,139 @@
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
function toArray(nodeList) {
|
||||
return Array.prototype.slice.call(nodeList || []);
|
||||
}
|
||||
|
||||
function initTaskCertTabs(root) {
|
||||
if (!(root instanceof window.HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.dataset.tabsInit === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
var tabs = toArray(root.querySelectorAll('[data-tabs-tab]'));
|
||||
var panels = toArray(root.querySelectorAll('[data-tabs-panel]'));
|
||||
if (tabs.length === 0 || panels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.tabsInit = '1';
|
||||
|
||||
function activate(targetKey) {
|
||||
var key = String(targetKey);
|
||||
var activeTabKey = key;
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
var isActive = tab.getAttribute('data-tabs-tab') === key;
|
||||
tab.classList.toggle('is-active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
|
||||
if (isActive) {
|
||||
activeTabKey = tab.getAttribute('data-tab-key') || key;
|
||||
}
|
||||
});
|
||||
|
||||
panels.forEach(function (panel) {
|
||||
var isActive = panel.getAttribute('data-tabs-panel') === key;
|
||||
panel.classList.toggle('is-active', isActive);
|
||||
panel.hidden = !isActive;
|
||||
});
|
||||
|
||||
toArray(root.querySelectorAll('[data-tabs-active-input]')).forEach(function (input) {
|
||||
if (input instanceof window.HTMLInputElement) {
|
||||
input.value = activeTabKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
activate(tab.getAttribute('data-tabs-tab'));
|
||||
});
|
||||
});
|
||||
|
||||
var initial = root.getAttribute('data-tabs-active');
|
||||
var initialTab = null;
|
||||
if (initial !== null) {
|
||||
for (var i = 0; i < tabs.length; i += 1) {
|
||||
if (tabs[i].getAttribute('data-tabs-tab') === initial) {
|
||||
initialTab = tabs[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (initialTab) {
|
||||
activate(initialTab.getAttribute('data-tabs-tab'));
|
||||
} else {
|
||||
activate(tabs[0].getAttribute('data-tabs-tab'));
|
||||
}
|
||||
|
||||
toArray(root.querySelectorAll('form[data-cycle-switch-form]')).forEach(function (form) {
|
||||
if (!(form instanceof window.HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset.cycleSwitchInit === '1') {
|
||||
return;
|
||||
}
|
||||
form.dataset.cycleSwitchInit = '1';
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
var method = String(form.getAttribute('method') || 'get').toUpperCase();
|
||||
if (method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
var action = form.getAttribute('action') || window.location.href;
|
||||
var actionUrl;
|
||||
try {
|
||||
actionUrl = new window.URL(action, window.location.origin);
|
||||
} catch (err) {
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
var params = new window.URLSearchParams(actionUrl.search || '');
|
||||
var formData = new window.FormData(form);
|
||||
formData.forEach(function (value, key) {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
params.set(key, value);
|
||||
});
|
||||
|
||||
params.delete('load_guard');
|
||||
var query = params.toString();
|
||||
var redirectUrl = actionUrl.pathname;
|
||||
if (query !== '') {
|
||||
redirectUrl += '?' + query + '&load_guard=/';
|
||||
} else {
|
||||
redirectUrl += '?load_guard=/';
|
||||
}
|
||||
|
||||
if (actionUrl.hash) {
|
||||
redirectUrl += actionUrl.hash;
|
||||
}
|
||||
|
||||
window.location.assign(redirectUrl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function autoInit() {
|
||||
toArray(document.querySelectorAll('[data-task-cert-tabs]')).forEach(initTaskCertTabs);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', autoInit);
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
})(window, document);
|
||||
700
module/tasks/bootstrap.php
Normal file
700
module/tasks/bootstrap.php
Normal file
@@ -0,0 +1,700 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/Logger.php';
|
||||
|
||||
if (!function_exists('task_cert_bootstrap_output_warning')) {
|
||||
function task_cert_bootstrap_output_warning(string $message): void
|
||||
{
|
||||
if (PHP_SAPI === 'cli') {
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
return;
|
||||
}
|
||||
|
||||
$safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$accept = (string) ($_SERVER['HTTP_ACCEPT'] ?? '');
|
||||
$isAjax = strtolower((string) ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
|
||||
|
||||
$expectsJson = (
|
||||
strpos($requestUri, '/endpoints/') !== false
|
||||
|| strpos($accept, 'application/json') !== false
|
||||
|| $isAjax
|
||||
);
|
||||
|
||||
if ($expectsJson) {
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $message,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
echo '<div class="task-cert"><div class="task-cert-error">' . $safeMessage . '</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_bootstrap_truthy')) {
|
||||
function task_cert_bootstrap_truthy(string $value): bool
|
||||
{
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on', 'always'], true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_is_endpoint_request')) {
|
||||
function task_cert_is_endpoint_request(): bool
|
||||
{
|
||||
$uri = strtolower((string) ($_SERVER['REQUEST_URI'] ?? ''));
|
||||
$scriptName = strtolower((string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||
$scriptFilename = strtolower((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''));
|
||||
|
||||
return (
|
||||
strpos($uri, '/endpoints/') !== false
|
||||
|| strpos($scriptName, '/endpoints/') !== false
|
||||
|| strpos($scriptFilename, '/endpoints/') !== false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_has_monolith_context')) {
|
||||
function task_cert_has_monolith_context(): bool
|
||||
{
|
||||
return (
|
||||
is_array($GLOBALS['site'] ?? null)
|
||||
&& is_array($GLOBALS['language'] ?? null)
|
||||
&& (
|
||||
is_array($GLOBALS['main_contact'] ?? null)
|
||||
|| isset($_SESSION['login_id'])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_bootstrap_context_fallback')) {
|
||||
function task_cert_bootstrap_context_fallback(): void
|
||||
{
|
||||
if (!is_array($GLOBALS['site'] ?? null)) {
|
||||
$GLOBALS['site'] = [];
|
||||
}
|
||||
if (!is_array($GLOBALS['language'] ?? null)) {
|
||||
$GLOBALS['language'] = [];
|
||||
}
|
||||
if (!is_array($GLOBALS['main_contact'] ?? null)) {
|
||||
$GLOBALS['main_contact'] = [];
|
||||
}
|
||||
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
$segments = array_values(array_filter(explode('/', trim((string) $path, '/')), static function (string $segment): bool {
|
||||
return $segment !== '';
|
||||
}));
|
||||
|
||||
$siteCode = '';
|
||||
$languageCode = '';
|
||||
if (!task_cert_is_endpoint_request()) {
|
||||
if (isset($segments[0])) {
|
||||
$siteCode = strtolower((string) (preg_replace('/[^a-z0-9_-]/i', '', $segments[0]) ?? ''));
|
||||
}
|
||||
if (isset($segments[1])) {
|
||||
$languageCode = strtolower((string) (preg_replace('/[^a-z0-9_-]/i', '', $segments[1]) ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
if (($GLOBALS['site']['code'] ?? '') === '') {
|
||||
$GLOBALS['site']['code'] = $siteCode !== '' ? $siteCode : 'intranet';
|
||||
}
|
||||
if (($GLOBALS['language']['code'] ?? '') === '') {
|
||||
$GLOBALS['language']['code'] = $languageCode !== '' ? $languageCode : 'de';
|
||||
}
|
||||
if (!isset($GLOBALS['site']['id'])) {
|
||||
$GLOBALS['site']['id'] = 0;
|
||||
}
|
||||
if (!isset($GLOBALS['language']['id'])) {
|
||||
$GLOBALS['language']['id'] = 1;
|
||||
}
|
||||
|
||||
$loginId = (int) ($_SESSION['login_id'] ?? ($_SESSION['main_contact_id'] ?? ($_SESSION['contact_id'] ?? 0)));
|
||||
if ((int) ($GLOBALS['main_contact']['id'] ?? 0) <= 0 && isset($_SESSION['main_contact']) && is_array($_SESSION['main_contact'])) {
|
||||
$GLOBALS['main_contact'] = $_SESSION['main_contact'];
|
||||
}
|
||||
|
||||
$conn = $GLOBALS['mysql_con'] ?? null;
|
||||
if ($loginId > 0 && (int) ($GLOBALS['main_contact']['id'] ?? 0) <= 0 && $conn instanceof mysqli) {
|
||||
try {
|
||||
$stmt = $conn->prepare('SELECT * FROM main_contact WHERE id = ? LIMIT 1');
|
||||
if ($stmt instanceof mysqli_stmt) {
|
||||
$stmt->bind_param('i', $loginId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
if ($result instanceof mysqli_result) {
|
||||
$row = $result->fetch_assoc();
|
||||
if (is_array($row)) {
|
||||
$GLOBALS['main_contact'] = $row;
|
||||
}
|
||||
$result->free();
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertLogger::warning('Fallback context: could not load main_contact', [
|
||||
'login_id' => $loginId,
|
||||
'exception' => $throwable,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) ($GLOBALS['main_contact']['id'] ?? 0) > 0 && !isset($_SESSION['main_contact'])) {
|
||||
$_SESSION['main_contact'] = $GLOBALS['main_contact'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('TASK_CERT_BOOTSTRAPPED')) {
|
||||
define('TASK_CERT_BOOTSTRAPPED', true);
|
||||
|
||||
$taskCertModuleDir = __DIR__;
|
||||
$rootCandidates = [
|
||||
dirname(dirname($taskCertModuleDir)),
|
||||
dirname($taskCertModuleDir),
|
||||
$taskCertModuleDir,
|
||||
];
|
||||
$rootDir = $rootCandidates[0];
|
||||
foreach ($rootCandidates as $candidate) {
|
||||
if (
|
||||
file_exists($candidate . '/mysyde/frontend/frontend_functions.inc.php') ||
|
||||
file_exists($candidate . '/vendor/autoload.php') ||
|
||||
is_dir($candidate . '/config')
|
||||
) {
|
||||
$rootDir = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('TASK_CERT_USERDATA_DIR')) {
|
||||
define('TASK_CERT_USERDATA_DIR', $rootDir . '/userdata');
|
||||
}
|
||||
|
||||
$autoloadFile = $rootDir . '/vendor/autoload.php';
|
||||
if (file_exists($autoloadFile)) {
|
||||
require_once $autoloadFile;
|
||||
}
|
||||
|
||||
$frontendFunctions = $rootDir . '/mysyde/frontend/frontend_functions.inc.php';
|
||||
if (file_exists($frontendFunctions)) {
|
||||
require_once $frontendFunctions;
|
||||
}
|
||||
|
||||
$commonFunctions = $rootDir . '/mysyde/common/common_functions.inc.php';
|
||||
if (file_exists($commonFunctions)) {
|
||||
require_once $commonFunctions;
|
||||
}
|
||||
|
||||
$intranetFunctionCandidates = [
|
||||
$rootDir . '/mysyde/intranet/intranet_functions.inc.php',
|
||||
$rootDir . '/mysyde/frontend/intranet_functions.inc.php',
|
||||
$rootDir . '/intranet_functions.inc.php',
|
||||
];
|
||||
foreach ($intranetFunctionCandidates as $intranetFunctions) {
|
||||
if (file_exists($intranetFunctions)) {
|
||||
require_once $intranetFunctions;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$envDir = $rootDir . '/config';
|
||||
$envFile = $envDir . '/.env';
|
||||
if (is_dir($envDir) && file_exists($envFile) && class_exists('\Dotenv\Dotenv')) {
|
||||
$dotenv = new \Dotenv\Dotenv($envDir);
|
||||
$dotenv->load();
|
||||
}
|
||||
|
||||
$connEnv = null;
|
||||
if (function_exists('local_environment')) {
|
||||
$connEnv = local_environment()
|
||||
? $rootDir . '/mysyde/dc.config.php'
|
||||
: $rootDir . '/mysyde/dc-server.config.php';
|
||||
}
|
||||
|
||||
if ($connEnv !== null && file_exists($connEnv)) {
|
||||
require_once $connEnv;
|
||||
}
|
||||
|
||||
$startIncPath = $rootDir . '/mysyde/frontend/start.inc.php';
|
||||
$startIncMode = strtolower(trim((string) (getenv('TASK_CERT_BOOTSTRAP_START_INC') ?: ($_ENV['TASK_CERT_BOOTSTRAP_START_INC'] ?? 'auto'))));
|
||||
$shouldLoadStartInc = false;
|
||||
|
||||
if ($startIncMode === 'always' || task_cert_bootstrap_truthy($startIncMode)) {
|
||||
$shouldLoadStartInc = true;
|
||||
} elseif (in_array($startIncMode, ['never', '0', 'false', 'off'], true)) {
|
||||
$shouldLoadStartInc = false;
|
||||
} else {
|
||||
// Auto mode: include start.inc for view-like requests, skip for direct endpoint calls.
|
||||
$shouldLoadStartInc = !task_cert_is_endpoint_request();
|
||||
}
|
||||
|
||||
if (!task_cert_has_monolith_context() && $shouldLoadStartInc && file_exists($startIncPath)) {
|
||||
try {
|
||||
require_once $startIncPath;
|
||||
TaskCertLogger::info('Monolith start.inc included', [
|
||||
'path' => $startIncPath,
|
||||
'mode' => $startIncMode,
|
||||
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertLogger::warning('Monolith start.inc include failed, continuing with fallback', [
|
||||
'path' => $startIncPath,
|
||||
'mode' => $startIncMode,
|
||||
'exception' => $throwable,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Endpoint-Requests überspringen start.inc.php (kein session_name-Setup durch den Monolith).
|
||||
// Cookie-Detection stellt sicher, dass wir dieselbe Session wie der Monolith verwenden
|
||||
// (Monolith setzt den Session-Namen auf "sid{site_code}", z.B. "sidawo").
|
||||
if (session_name() === 'PHPSESSID') {
|
||||
foreach (array_keys($_COOKIE) as $_tc_sn) {
|
||||
if (strncmp($_tc_sn, 'sid', 3) === 0) {
|
||||
session_name($_tc_sn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($_tc_sn);
|
||||
}
|
||||
session_start();
|
||||
}
|
||||
|
||||
$dbConnectError = '';
|
||||
if (!isset($GLOBALS['mysql_con']) || !($GLOBALS['mysql_con'] instanceof mysqli)) {
|
||||
if (function_exists('db_connect')) {
|
||||
try {
|
||||
db_connect();
|
||||
} catch (Throwable $throwable) {
|
||||
$dbConnectError = $throwable->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($GLOBALS['mysql_con']) || !($GLOBALS['mysql_con'] instanceof mysqli)) {
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$mysqlCon = @mysqli_connect(
|
||||
getenv('MAIN_MYSQL_DB_HOST') ?: '127.0.0.1',
|
||||
getenv('MAIN_MYSQL_DB_USER') ?: '',
|
||||
getenv('MAIN_MYSQL_DB_PASS') ?: '',
|
||||
getenv('MAIN_MYSQL_DB_SCHEMA') ?: '',
|
||||
(int) (getenv('MAIN_MYSQL_DB_PORT') ?: 3306)
|
||||
);
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
|
||||
if ($mysqlCon) {
|
||||
mysqli_set_charset($mysqlCon, 'utf8mb4');
|
||||
$GLOBALS['mysql_con'] = $mysqlCon;
|
||||
} else {
|
||||
$driverError = mysqli_connect_error();
|
||||
$errorParts = [];
|
||||
|
||||
if ($dbConnectError !== '') {
|
||||
$errorParts[] = 'Legacy-Connector: ' . $dbConnectError;
|
||||
}
|
||||
|
||||
if ($driverError !== '') {
|
||||
$errorParts[] = 'mysqli: ' . $driverError;
|
||||
}
|
||||
|
||||
$details = $errorParts !== [] ? ' Details: ' . implode(' | ', $errorParts) : '';
|
||||
$message = 'task_cert: Datenbankverbindung konnte nicht aufgebaut werden.' . $details;
|
||||
|
||||
$GLOBALS['task_cert_bootstrap_warning'] = $message;
|
||||
TaskCertLogger::error('Bootstrap DB connection failed', [
|
||||
'message' => $message,
|
||||
'db_connect_error' => $dbConnectError,
|
||||
'mysqli_error' => $driverError,
|
||||
]);
|
||||
task_cert_bootstrap_output_warning($message);
|
||||
exit(PHP_SAPI === 'cli' ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!task_cert_has_monolith_context()) {
|
||||
task_cert_bootstrap_context_fallback();
|
||||
TaskCertLogger::info('Monolith context fallback applied', [
|
||||
'site_code' => (string) ($GLOBALS['site']['code'] ?? ''),
|
||||
'language_code' => (string) ($GLOBALS['language']['code'] ?? ''),
|
||||
'contact_id' => (int) ($GLOBALS['main_contact']['id'] ?? 0),
|
||||
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/lib/Db.php';
|
||||
require_once __DIR__ . '/lib/AuthContext.php';
|
||||
require_once __DIR__ . '/lib/Request.php';
|
||||
require_once __DIR__ . '/lib/Response.php';
|
||||
require_once __DIR__ . '/lib/View.php';
|
||||
require_once __DIR__ . '/lib/Csrf.php';
|
||||
require_once __DIR__ . '/lib/ContactPrefillColumns.php';
|
||||
require_once __DIR__ . '/lib/CronHeartbeat.php';
|
||||
require_once __DIR__ . '/lib/TaskCertConstants.php';
|
||||
require_once __DIR__ . '/lib/TaskUploadPolicy.php';
|
||||
require_once __DIR__ . '/lib/TaskUploadStorage.php';
|
||||
require_once __DIR__ . '/lib/Html.php';
|
||||
|
||||
require_once __DIR__ . '/repo/TaskDefinitionRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskChangeLogRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskCategoryRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskCategoryChangeLogRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskLookupRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskMonitoringRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskRuleRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskAssignmentRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskSubmissionRepository.php';
|
||||
require_once __DIR__ . '/repo/TaskCertificateRepository.php';
|
||||
|
||||
require_once __DIR__ . '/service/TaskDefinitionService.php';
|
||||
require_once __DIR__ . '/service/TaskCategoryService.php';
|
||||
require_once __DIR__ . '/service/TaskLookupService.php';
|
||||
require_once __DIR__ . '/service/TaskMonitoringService.php';
|
||||
require_once __DIR__ . '/service/CycleCalculatorService.php';
|
||||
require_once __DIR__ . '/service/AssignmentResolverService.php';
|
||||
require_once __DIR__ . '/service/CompletionService.php';
|
||||
require_once __DIR__ . '/service/ApprovalService.php';
|
||||
require_once __DIR__ . '/service/CertificateService.php';
|
||||
require_once __DIR__ . '/service/EscalationService.php';
|
||||
}
|
||||
|
||||
function task_cert_services(): array
|
||||
{
|
||||
static $services = null;
|
||||
|
||||
if ($services !== null) {
|
||||
return $services;
|
||||
}
|
||||
|
||||
$definitionRepo = new TaskDefinitionRepository();
|
||||
$changeLogRepo = new TaskChangeLogRepository();
|
||||
$taskCategoryRepo = new TaskCategoryRepository();
|
||||
$taskCategoryChangeLogRepo = new TaskCategoryChangeLogRepository();
|
||||
$lookupRepo = new TaskLookupRepository();
|
||||
$monitoringRepo = new TaskMonitoringRepository();
|
||||
$ruleRepo = new TaskRuleRepository();
|
||||
$assignmentRepo = new TaskAssignmentRepository();
|
||||
$submissionRepo = new TaskSubmissionRepository();
|
||||
$certificateRepo = new TaskCertificateRepository();
|
||||
$cycleCalculatorService = new CycleCalculatorService();
|
||||
|
||||
$certificateService = new CertificateService(
|
||||
$certificateRepo,
|
||||
$assignmentRepo,
|
||||
$definitionRepo,
|
||||
$cycleCalculatorService
|
||||
);
|
||||
$assignmentResolverService = new AssignmentResolverService(
|
||||
$definitionRepo,
|
||||
$ruleRepo,
|
||||
$assignmentRepo,
|
||||
$submissionRepo,
|
||||
$certificateRepo,
|
||||
$cycleCalculatorService,
|
||||
$changeLogRepo
|
||||
);
|
||||
|
||||
$taskDefinitionService = new TaskDefinitionService(
|
||||
$definitionRepo,
|
||||
$taskCategoryRepo,
|
||||
$ruleRepo,
|
||||
$assignmentRepo,
|
||||
$submissionRepo,
|
||||
$certificateRepo,
|
||||
$cycleCalculatorService,
|
||||
$assignmentResolverService,
|
||||
$changeLogRepo
|
||||
);
|
||||
|
||||
$completionService = new CompletionService(
|
||||
$ruleRepo,
|
||||
$assignmentRepo,
|
||||
$submissionRepo,
|
||||
$certificateService,
|
||||
$definitionRepo,
|
||||
$assignmentResolverService
|
||||
);
|
||||
|
||||
$services = [
|
||||
'definitionRepo' => $definitionRepo,
|
||||
'taskCategoryRepo' => $taskCategoryRepo,
|
||||
'taskCategoryChangeLogRepo' => $taskCategoryChangeLogRepo,
|
||||
'lookupRepo' => $lookupRepo,
|
||||
'changeLogRepo' => $changeLogRepo,
|
||||
'monitoringRepo' => $monitoringRepo,
|
||||
'ruleRepo' => $ruleRepo,
|
||||
'assignmentRepo' => $assignmentRepo,
|
||||
'submissionRepo' => $submissionRepo,
|
||||
'certificateRepo' => $certificateRepo,
|
||||
'cycleCalculatorService' => $cycleCalculatorService,
|
||||
'taskCategoryService' => new TaskCategoryService(
|
||||
$taskCategoryRepo,
|
||||
$taskCategoryChangeLogRepo
|
||||
),
|
||||
'lookupService' => new TaskLookupService($lookupRepo),
|
||||
'taskMonitoringService' => new TaskMonitoringService(
|
||||
$monitoringRepo,
|
||||
$cycleCalculatorService,
|
||||
$ruleRepo
|
||||
),
|
||||
'taskDefinitionService' => $taskDefinitionService,
|
||||
'assignmentResolverService' => $assignmentResolverService,
|
||||
'completionService' => $completionService,
|
||||
'approvalService' => new ApprovalService(
|
||||
$submissionRepo,
|
||||
$assignmentRepo,
|
||||
$completionService
|
||||
),
|
||||
'certificateService' => $certificateService,
|
||||
'escalationService' => new EscalationService(
|
||||
$assignmentRepo,
|
||||
$ruleRepo,
|
||||
$changeLogRepo
|
||||
),
|
||||
];
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
function task_cert_asset_url(string $relativeAssetPath): string
|
||||
{
|
||||
$cleanPath = ltrim($relativeAssetPath, '/');
|
||||
return task_cert_url('assets/' . $cleanPath);
|
||||
}
|
||||
|
||||
function task_cert_base_url(): string
|
||||
{
|
||||
static $baseUrl = null;
|
||||
|
||||
if ($baseUrl !== null) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
$configured = (string) (getenv('TASK_CERT_BASE_URL') ?: ($_ENV['TASK_CERT_BASE_URL'] ?? ''));
|
||||
if (trim($configured) === '') {
|
||||
$configured = '/module/tasks';
|
||||
}
|
||||
|
||||
$configured = '/' . trim($configured, '/');
|
||||
$baseUrl = ($configured === '/') ? '' : $configured;
|
||||
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
function task_cert_site_code(): string
|
||||
{
|
||||
$siteCode = '';
|
||||
|
||||
$siteGlobal = $GLOBALS['site'] ?? null;
|
||||
if (is_array($siteGlobal)) {
|
||||
$siteCode = (string) ($siteGlobal['code'] ?? ($siteGlobal['site_code'] ?? ''));
|
||||
}
|
||||
|
||||
if ($siteCode === '') {
|
||||
$siteCode = (string) ($GLOBALS['site_code'] ?? '');
|
||||
}
|
||||
|
||||
if ($siteCode === '' && isset($_SESSION['site']) && is_array($_SESSION['site'])) {
|
||||
$siteCode = (string) ($_SESSION['site']['code'] ?? ($_SESSION['site']['site_code'] ?? ''));
|
||||
}
|
||||
|
||||
if ($siteCode === '') {
|
||||
$siteCode = (string) ($_SESSION['site_code'] ?? '');
|
||||
}
|
||||
|
||||
if ($siteCode === '') {
|
||||
if (!task_cert_is_endpoint_request()) {
|
||||
$requestPath = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$segments = array_values(array_filter(explode('/', trim($requestPath, '/'))));
|
||||
if (isset($segments[0])) {
|
||||
$siteCode = (string) $segments[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$siteCode = strtolower(trim($siteCode));
|
||||
$siteCode = preg_replace('/[^a-z0-9_-]/', '', $siteCode) ?? '';
|
||||
|
||||
return $siteCode !== '' ? $siteCode : 'intranet';
|
||||
}
|
||||
|
||||
function task_cert_language_code(): string
|
||||
{
|
||||
$languageCode = '';
|
||||
|
||||
$languageGlobal = $GLOBALS['language'] ?? null;
|
||||
if (is_array($languageGlobal)) {
|
||||
$languageCode = (string) ($languageGlobal['code'] ?? '');
|
||||
}
|
||||
|
||||
if ($languageCode === '' && isset($_SESSION['language']) && is_array($_SESSION['language'])) {
|
||||
$languageCode = (string) ($_SESSION['language']['code'] ?? '');
|
||||
}
|
||||
|
||||
if ($languageCode === '') {
|
||||
$languageCode = (string) ($GLOBALS['language_code'] ?? ($_SESSION['language_code'] ?? ''));
|
||||
}
|
||||
|
||||
if ($languageCode === '') {
|
||||
if (!task_cert_is_endpoint_request()) {
|
||||
$requestPath = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
||||
$segments = array_values(array_filter(explode('/', trim($requestPath, '/'))));
|
||||
if (isset($segments[1])) {
|
||||
$languageCode = (string) $segments[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$languageCode = strtolower(trim($languageCode));
|
||||
$languageCode = preg_replace('/[^a-z0-9_-]/', '', $languageCode) ?? '';
|
||||
|
||||
return $languageCode !== '' ? $languageCode : 'de';
|
||||
}
|
||||
|
||||
function task_cert_view_base_url(): string
|
||||
{
|
||||
static $baseUrl = null;
|
||||
|
||||
if ($baseUrl !== null) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
$configured = trim((string) (getenv('TASK_CERT_VIEW_BASE_URL') ?: ($_ENV['TASK_CERT_VIEW_BASE_URL'] ?? '')));
|
||||
if ($configured !== '') {
|
||||
$configured = '/' . trim($configured, '/');
|
||||
$baseUrl = ($configured === '/') ? '' : $configured;
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
$slug = trim((string) (getenv('TASK_CERT_VIEW_SLUG') ?: ($_ENV['TASK_CERT_VIEW_SLUG'] ?? 'tasks-cert')));
|
||||
$slug = strtolower($slug);
|
||||
$slug = preg_replace('/[^a-z0-9_-]/', '', $slug) ?? '';
|
||||
if ($slug === '') {
|
||||
$slug = 'tasks-cert';
|
||||
}
|
||||
|
||||
$baseUrl = '/' . task_cert_site_code() . '/' . task_cert_language_code() . '/' . $slug;
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
function task_cert_view_url(string $viewKey = 'admin.task_list', array $query = []): string
|
||||
{
|
||||
$viewMap = [
|
||||
'admin.task_list' => 'admin-task-list',
|
||||
'admin.task_form' => 'edit',
|
||||
'admin.task_detail' => 'view',
|
||||
'admin.approval_queue' => 'approvals',
|
||||
'admin.escalation_dashboard' => 'escalations',
|
||||
'admin.task_categories' => 'categories',
|
||||
'user.my_tasks' => 'my-tasks',
|
||||
'user.task_detail' => 'task',
|
||||
'user.overdue_widget' => 'overdue-widget',
|
||||
'user.summary_widget' => 'summary-widget',
|
||||
'user.escalation_overlay_widget' => 'escalation-overlay-widget',
|
||||
];
|
||||
|
||||
$relativePath = $viewMap[$viewKey] ?? trim($viewKey);
|
||||
$base = rtrim(task_cert_view_base_url(), '/');
|
||||
$cleanPath = trim((string) $relativePath, '/');
|
||||
|
||||
$url = $base;
|
||||
if ($cleanPath !== '') {
|
||||
$url .= '/' . $cleanPath . '/';
|
||||
} else {
|
||||
$url .= '/';
|
||||
}
|
||||
|
||||
if ($viewKey === 'admin.task_detail' && !array_key_exists('route_guard', $query)) {
|
||||
// Guard param prevents monolith route edge cases when URL ends with numeric query values.
|
||||
$query['route_guard'] = 'load/';
|
||||
}
|
||||
|
||||
if ($query !== []) {
|
||||
$queryString = http_build_query($query);
|
||||
if ($queryString !== '') {
|
||||
$url .= '?' . $queryString;
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
function task_cert_url(string $relativePath = ''): string
|
||||
{
|
||||
$base = task_cert_base_url();
|
||||
$cleanPath = ltrim(trim($relativePath), '/');
|
||||
|
||||
if ($cleanPath === '') {
|
||||
return $base === '' ? '/' : $base;
|
||||
}
|
||||
|
||||
return ($base === '' ? '' : $base) . '/' . $cleanPath;
|
||||
}
|
||||
|
||||
function task_cert_asset_versioned_url(string $relativeAssetPath): string
|
||||
{
|
||||
$cleanPath = ltrim($relativeAssetPath, '/');
|
||||
$absolutePath = __DIR__ . '/assets/' . $cleanPath;
|
||||
$version = file_exists($absolutePath) ? (string) filemtime($absolutePath) : (string) time();
|
||||
return task_cert_asset_url($cleanPath) . '?v=' . rawurlencode($version);
|
||||
}
|
||||
|
||||
function task_cert_render_styles(array $variants = [], array $extraFiles = []): void
|
||||
{
|
||||
static $printed = [];
|
||||
|
||||
$files = [
|
||||
'css/task_cert.css',
|
||||
];
|
||||
|
||||
if (in_array('admin', $variants, true)) {
|
||||
$files[] = 'css/task_cert_admin.css';
|
||||
}
|
||||
|
||||
if (in_array('user', $variants, true)) {
|
||||
$files[] = 'css/task_cert_user.css';
|
||||
}
|
||||
|
||||
$files = array_values(array_unique(array_merge($files, $extraFiles)));
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (isset($printed[$file])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = task_cert_asset_versioned_url($file);
|
||||
|
||||
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '">' . PHP_EOL;
|
||||
$printed[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function task_cert_render_scripts(array $files): void
|
||||
{
|
||||
static $printed = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$cleanFile = ltrim((string) $file, '/');
|
||||
if ($cleanFile === '' || isset($printed[$cleanFile])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = task_cert_asset_versioned_url($cleanFile);
|
||||
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8') . '" defer></script>' . PHP_EOL;
|
||||
$printed[$cleanFile] = true;
|
||||
}
|
||||
}
|
||||
259
module/tasks/cli/CronRuntime.php
Normal file
259
module/tasks/cli/CronRuntime.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class TaskCertCronRuntime
|
||||
{
|
||||
public static function parseOptions(array $argv, bool $allowTaskId = true): array
|
||||
{
|
||||
$options = [
|
||||
'apply' => false,
|
||||
'dry_run' => false,
|
||||
'json' => false,
|
||||
'no_lock' => false,
|
||||
'task_id' => null,
|
||||
'help' => false,
|
||||
];
|
||||
|
||||
$count = count($argv);
|
||||
for ($i = 1; $i < $count; $i++) {
|
||||
$arg = (string) $argv[$i];
|
||||
|
||||
if ($arg === '--apply') {
|
||||
$options['apply'] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--dry-run') {
|
||||
$options['dry_run'] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--json') {
|
||||
$options['json'] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--no-lock') {
|
||||
$options['no_lock'] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--help') {
|
||||
$options['help'] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($arg, '--task_id=') === 0) {
|
||||
if (!$allowTaskId) {
|
||||
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
|
||||
}
|
||||
$raw = trim(substr($arg, strlen('--task_id=')));
|
||||
if ($raw === '' || !ctype_digit($raw)) {
|
||||
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
|
||||
}
|
||||
$options['task_id'] = (int) $raw;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--task_id') {
|
||||
if (!$allowTaskId) {
|
||||
throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.');
|
||||
}
|
||||
$next = $argv[$i + 1] ?? null;
|
||||
if (!is_string($next) || trim($next) === '' || !ctype_digit(trim($next))) {
|
||||
throw new InvalidArgumentException('Ungültiger Wert für --task_id.');
|
||||
}
|
||||
$options['task_id'] = (int) trim($next);
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unbekannte Option: ' . $arg);
|
||||
}
|
||||
|
||||
if ($options['apply'] && $options['dry_run']) {
|
||||
throw new InvalidArgumentException('--apply und --dry-run können nicht gleichzeitig gesetzt werden.');
|
||||
}
|
||||
|
||||
if ($options['task_id'] !== null && (int) $options['task_id'] <= 0) {
|
||||
throw new InvalidArgumentException('--task_id muss > 0 sein.');
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function runWithHandling(string $jobName, array $options, callable $runner): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$runId = self::buildRunId($jobName);
|
||||
$mode = $options['apply'] ? 'apply' : 'dry_run';
|
||||
$lockHandle = null;
|
||||
|
||||
$basePayload = [
|
||||
'timestamp' => date('c'),
|
||||
'job' => $jobName,
|
||||
'job_run_id' => $runId,
|
||||
'mode' => $mode,
|
||||
'task_id' => $options['task_id'],
|
||||
];
|
||||
|
||||
try {
|
||||
if (!$options['no_lock']) {
|
||||
$lockResult = self::acquireLock($jobName);
|
||||
$lockHandle = $lockResult['handle'] ?? null;
|
||||
$acquired = (bool) ($lockResult['acquired'] ?? false);
|
||||
if (!$acquired) {
|
||||
$payload = $basePayload + [
|
||||
'success' => false,
|
||||
'skipped' => true,
|
||||
'skip_reason' => 'lock_active',
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
];
|
||||
self::writeCronLog($payload);
|
||||
if ((bool) ($options['json'] ?? false)) {
|
||||
self::emitJson($payload);
|
||||
} else {
|
||||
fwrite(STDOUT, '[SKIP] ' . $jobName . ': Lock aktiv, Job wird übersprungen.' . PHP_EOL);
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
$context = [
|
||||
'job_run_id' => $runId,
|
||||
'mode' => $mode,
|
||||
'task_id' => $options['task_id'],
|
||||
];
|
||||
|
||||
$summary = $runner($context);
|
||||
if (!is_array($summary)) {
|
||||
$summary = [];
|
||||
}
|
||||
|
||||
$payload = $basePayload + [
|
||||
'success' => true,
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
'summary' => $summary,
|
||||
];
|
||||
self::writeCronLog($payload);
|
||||
if ((bool) ($options['json'] ?? false)) {
|
||||
self::emitJson($payload);
|
||||
} else {
|
||||
fwrite(STDOUT, '[OK] ' . $jobName . ' (' . $mode . ') abgeschlossen in ' . $payload['duration_ms'] . 'ms.' . PHP_EOL);
|
||||
}
|
||||
return 0;
|
||||
} catch (Throwable $throwable) {
|
||||
$payload = $basePayload + [
|
||||
'success' => false,
|
||||
'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
self::writeCronLog($payload);
|
||||
if ((bool) ($options['json'] ?? false)) {
|
||||
self::emitJson($payload);
|
||||
} else {
|
||||
fwrite(STDERR, '[ERROR] ' . $jobName . ' (' . $mode . '): ' . $throwable->getMessage() . PHP_EOL);
|
||||
}
|
||||
return 1;
|
||||
} finally {
|
||||
self::releaseLock($lockHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public static function printUsage(string $usage): void
|
||||
{
|
||||
fwrite(STDOUT, trim($usage) . PHP_EOL);
|
||||
}
|
||||
|
||||
public static function printOptionError(string $message, string $usage): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL . PHP_EOL . trim($usage) . PHP_EOL);
|
||||
}
|
||||
|
||||
private static function acquireLock(string $jobName): array
|
||||
{
|
||||
$lockDir = self::logsDir() . '/locks';
|
||||
if (!is_dir($lockDir)) {
|
||||
@mkdir($lockDir, 0775, true);
|
||||
}
|
||||
|
||||
$lockPath = $lockDir . '/task_cert_' . $jobName . '.lock';
|
||||
$handle = @fopen($lockPath, 'c+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Lock-Datei konnte nicht geöffnet werden: ' . $lockPath);
|
||||
}
|
||||
|
||||
$acquired = flock($handle, LOCK_EX | LOCK_NB);
|
||||
if (!$acquired) {
|
||||
return [
|
||||
'acquired' => false,
|
||||
'handle' => $handle,
|
||||
];
|
||||
}
|
||||
|
||||
$meta = [
|
||||
'pid' => getmypid(),
|
||||
'started_at' => date('c'),
|
||||
];
|
||||
ftruncate($handle, 0);
|
||||
rewind($handle);
|
||||
fwrite($handle, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
fflush($handle);
|
||||
|
||||
return [
|
||||
'acquired' => true,
|
||||
'handle' => $handle,
|
||||
];
|
||||
}
|
||||
|
||||
private static function releaseLock($lockHandle): void
|
||||
{
|
||||
if (!is_resource($lockHandle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@flock($lockHandle, LOCK_UN);
|
||||
@fclose($lockHandle);
|
||||
}
|
||||
|
||||
private static function writeCronLog(array $payload): void
|
||||
{
|
||||
$logDir = self::logsDir();
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
|
||||
$file = $logDir . '/task_cert_cron.log';
|
||||
$line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($line) || $line === '') {
|
||||
$line = json_encode([
|
||||
'timestamp' => date('c'),
|
||||
'success' => false,
|
||||
'error' => 'cron_log_encode_failed',
|
||||
]);
|
||||
}
|
||||
|
||||
@file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function emitJson(array $payload): void
|
||||
{
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
}
|
||||
|
||||
private static function logsDir(): string
|
||||
{
|
||||
return dirname(__DIR__) . '/logs';
|
||||
}
|
||||
|
||||
private static function buildRunId(string $jobName): string
|
||||
{
|
||||
try {
|
||||
$entropy = bin2hex(random_bytes(6));
|
||||
} catch (Throwable $throwable) {
|
||||
$entropy = substr(sha1((string) microtime(true)), 0, 12);
|
||||
}
|
||||
|
||||
return $jobName . '-' . date('YmdHis') . '-' . $entropy;
|
||||
}
|
||||
}
|
||||
52
module/tasks/cli/task_cert_materialize_assignments.php
Normal file
52
module/tasks/cli/task_cert_materialize_assignments.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/CronRuntime.php';
|
||||
|
||||
$usage = <<<TXT
|
||||
Usage:
|
||||
php cli/task_cert_materialize_assignments.php [--no-lock] [--json]
|
||||
|
||||
Options:
|
||||
--no-lock Locking deaktivieren (nur Debug)
|
||||
--json JSON-Ausgabe zusätzlich auf stdout
|
||||
--help Diese Hilfe anzeigen
|
||||
TXT;
|
||||
|
||||
try {
|
||||
$options = TaskCertCronRuntime::parseOptions($argv, false);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
if ((bool) ($options['help'] ?? false)) {
|
||||
TaskCertCronRuntime::printUsage($usage);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$options['apply'] = true;
|
||||
|
||||
$exitCode = TaskCertCronRuntime::runWithHandling(
|
||||
'materialize_assignments',
|
||||
$options,
|
||||
static function (): array {
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
$services = task_cert_services();
|
||||
$resolver = $services['assignmentResolverService'] ?? null;
|
||||
if (!$resolver instanceof AssignmentResolverService) {
|
||||
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
|
||||
}
|
||||
|
||||
$count = $resolver->materializeAssignmentsForAllActiveTasks();
|
||||
|
||||
CronHeartbeat::record('materialize_assignments', ['materialized' => $count]);
|
||||
|
||||
return [
|
||||
'job' => 'materialize_assignments',
|
||||
'materialized' => $count,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
exit($exitCode);
|
||||
60
module/tasks/cli/task_cert_recalculate_escalations.php
Normal file
60
module/tasks/cli/task_cert_recalculate_escalations.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/CronRuntime.php';
|
||||
|
||||
$usage = <<<TXT
|
||||
Usage:
|
||||
php cli/task_cert_recalculate_escalations.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
|
||||
|
||||
Options:
|
||||
--apply Mutierender Lauf (Status/Eskalationslevel schreiben)
|
||||
--dry-run Nur Vorschau, keine DB-Änderungen
|
||||
--task_id Optional: nur eine Aufgabe verarbeiten
|
||||
--no-lock Locking deaktivieren (nur Debug)
|
||||
--json JSON-Ausgabe zusätzlich auf stdout
|
||||
--help Diese Hilfe anzeigen
|
||||
TXT;
|
||||
|
||||
try {
|
||||
$options = TaskCertCronRuntime::parseOptions($argv, true);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
if ((bool) ($options['help'] ?? false)) {
|
||||
TaskCertCronRuntime::printUsage($usage);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$exitCode = TaskCertCronRuntime::runWithHandling(
|
||||
'recalculate_escalations',
|
||||
$options,
|
||||
static function () use ($options): array {
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
$services = task_cert_services();
|
||||
$escalationService = $services['escalationService'] ?? null;
|
||||
if (!$escalationService instanceof EscalationService) {
|
||||
throw new RuntimeException('EscalationService nicht verfügbar.');
|
||||
}
|
||||
|
||||
$apply = (bool) ($options['apply'] ?? false);
|
||||
$taskId = (int) ($options['task_id'] ?? 0);
|
||||
|
||||
$summary = $escalationService->recalculateEscalationsDetailed(
|
||||
$apply,
|
||||
$taskId > 0 ? $taskId : null
|
||||
);
|
||||
|
||||
if ($apply) {
|
||||
CronHeartbeat::record('recalculate_escalations', [
|
||||
'changed' => (int) ($summary['changed_assignments'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
);
|
||||
|
||||
exit($exitCode);
|
||||
108
module/tasks/cli/task_cert_sync_assignments.php
Normal file
108
module/tasks/cli/task_cert_sync_assignments.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/CronRuntime.php';
|
||||
|
||||
$usage = <<<TXT
|
||||
Usage:
|
||||
php cli/task_cert_sync_assignments.php [--apply|--dry-run] [--task_id=<id>] [--no-lock] [--json]
|
||||
|
||||
Options:
|
||||
--apply Mutierender Lauf (Zuweisungen anlegen/entfernen)
|
||||
--dry-run Nur Vorschau, keine DB-Änderungen
|
||||
--task_id Optional: nur eine Aufgabe verarbeiten
|
||||
--no-lock Locking deaktivieren (nur Debug)
|
||||
--json JSON-Ausgabe zusätzlich auf stdout
|
||||
--help Diese Hilfe anzeigen
|
||||
TXT;
|
||||
|
||||
try {
|
||||
$options = TaskCertCronRuntime::parseOptions($argv, true);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
TaskCertCronRuntime::printOptionError($exception->getMessage(), $usage);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
if ((bool) ($options['help'] ?? false)) {
|
||||
TaskCertCronRuntime::printUsage($usage);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$exitCode = TaskCertCronRuntime::runWithHandling(
|
||||
'sync_assignments',
|
||||
$options,
|
||||
static function (array $context) use ($options): array {
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
$services = task_cert_services();
|
||||
$resolver = $services['assignmentResolverService'] ?? null;
|
||||
if (!$resolver instanceof AssignmentResolverService) {
|
||||
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
|
||||
}
|
||||
|
||||
$apply = (bool) ($options['apply'] ?? false);
|
||||
$taskId = (int) ($options['task_id'] ?? 0);
|
||||
$auditContext = [
|
||||
'changed_by' => 0,
|
||||
'trigger' => 'cron_cli',
|
||||
'job_run_id' => (string) ($context['job_run_id'] ?? ''),
|
||||
];
|
||||
|
||||
if ($taskId > 0) {
|
||||
$results = [$resolver->syncAssignmentsForTask($taskId, $apply, 'current_cycle', $auditContext)];
|
||||
} else {
|
||||
$results = $resolver->syncAssignmentsForAllActiveTasks($apply, 'current_cycle', $auditContext);
|
||||
}
|
||||
|
||||
$totals = [
|
||||
'task_count' => 0,
|
||||
'target_count' => 0,
|
||||
'existing_count' => 0,
|
||||
'create_count' => 0,
|
||||
'update_count' => 0,
|
||||
'stale_count' => 0,
|
||||
'protected_completed_count' => 0,
|
||||
'protected_certificate_count' => 0,
|
||||
'remove_count' => 0,
|
||||
'remove_with_submissions_count' => 0,
|
||||
];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totals['task_count']++;
|
||||
foreach ([
|
||||
'target_count',
|
||||
'existing_count',
|
||||
'create_count',
|
||||
'update_count',
|
||||
'stale_count',
|
||||
'protected_completed_count',
|
||||
'protected_certificate_count',
|
||||
'remove_count',
|
||||
'remove_with_submissions_count',
|
||||
] as $key) {
|
||||
$totals[$key] += (int) ($result[$key] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($apply && $taskId <= 0) {
|
||||
CronHeartbeat::record('sync_assignments', [
|
||||
'task_count' => (int) ($totals['task_count'] ?? 0),
|
||||
'create_count' => (int) ($totals['create_count'] ?? 0),
|
||||
'remove_count' => (int) ($totals['remove_count'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'job' => 'sync_assignments',
|
||||
'mode' => $apply ? 'apply' : 'dry_run',
|
||||
'scope' => 'current_cycle',
|
||||
'task_id' => $taskId > 0 ? $taskId : null,
|
||||
'totals' => $totals,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
exit($exitCode);
|
||||
339
module/tasks/doc/task-cert-module-lifecycle.md
Normal file
339
module/tasks/doc/task-cert-module-lifecycle.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Task-Cert Modul: Architektur und Lifecycle
|
||||
|
||||
## Zweck
|
||||
Dieses Modul verwaltet Aufgaben inkl. Zielgruppen-Zuweisung, Erledigungsmethoden, Freigabeprozess, Zertifikaten, Eskalationen und Monitoring.
|
||||
|
||||
## Verzeichnisstruktur
|
||||
- `bootstrap.php`: Initialisierung, DI, URL-Mapping, Asset-Helper.
|
||||
- `pages/`: Views (Admin + User).
|
||||
- `actions/`: View-Controller (Datenaufbereitung pro Seite).
|
||||
- `endpoints/`: Schreibende Endpunkte (POST/CLI).
|
||||
- `service/`: Business-Logik.
|
||||
- `repo/`: DB-Zugriff.
|
||||
- `sql/`: Schema + Migrationen.
|
||||
|
||||
## Zentrale Services
|
||||
- `TaskDefinitionService`: Aufgabe anlegen/ändern, Reset aktueller Zyklus, Soft-/Hard-Delete, Audit-Log.
|
||||
- `AssignmentResolverService`: materialisiert Zuweisungen aus Target-Regeln + Overrides.
|
||||
- `CycleCalculatorService`: berechnet Zyklus (`cycle_key`) und Fälligkeit (`due_at`) für kalender- und erledigungsbasierte Wiederholung.
|
||||
- `CompletionService`: verarbeitet User-Einreichungen und bewertet Methodenabschluss.
|
||||
- `ApprovalService`: Admin-Freigabe/Ablehnung manueller Einreichungen.
|
||||
- `CertificateService`: Zertifikate ausstellen/widerrufen/ablaufen lassen.
|
||||
- `EscalationService`: setzt Überfällig-Status und Eskalationslevel.
|
||||
- `TaskMonitoringService`: Admin-Detaildashboard (KPIs, Freigaben, Überschreitungen, Antworten).
|
||||
|
||||
## Datenmodell (Kernobjekte)
|
||||
- `task_definition`: Aufgabenstammdaten inkl. Kategorie, Rhythmus, Rhythmus-Basis (`calendar|completion`), Fälligkeitslogik, Zertifikatsmodus.
|
||||
- `task_category`: Kategorien inkl. Admin-CRUD (inline) und Schutzregeln.
|
||||
- `task_category_change_log`: Audit-Log für Kategorieänderungen.
|
||||
- `task_method` + Untertabellen:
|
||||
- `task_method_knowledge_post`
|
||||
- `task_form_field`
|
||||
- `task_quiz_question`
|
||||
- `task_quiz_option`
|
||||
- `task_target_rule` + `task_target_override`: Zielgruppenlogik.
|
||||
- `task_assignment`: Instanz pro Mitarbeiter + Zyklus (Status, Due-Date, Eskalation).
|
||||
- `task_submission`: Einreichungen pro Methode.
|
||||
- `task_certificate`: Zertifikate pro Assignment.
|
||||
- `task_reminder_policy`: Eskalationsregeln.
|
||||
- `task_escalation_event`: Historie der Eskalationslevel-Events.
|
||||
- `task_change_log`: Audit-Log für Task-Konfigurationsänderungen.
|
||||
|
||||
Referenz: `sql/init.sql`
|
||||
|
||||
## Statusmodelle
|
||||
### Assignment-Status (`task_assignment.status`)
|
||||
- `open` -> `in_progress` -> `completed`
|
||||
- Sonder-/Nebenpfade: `approval_pending`, `overdue`, `rejected`, `expired`
|
||||
|
||||
### Submission-Status (`task_submission.status`)
|
||||
- `submitted`, `auto_passed`, `awaiting_approval`, `approved`, `rejected`
|
||||
|
||||
Wichtig:
|
||||
- `escalation_level = 0` bedeutet nur „keine aktive Eskalationsstufe“.
|
||||
- Ein Assignment kann trotzdem überfällig sein (z. B. `status = overdue`, Eskalation noch nicht gestartet).
|
||||
|
||||
## Lifecycle Ende-zu-Ende
|
||||
|
||||
### 1) Aufgabe erstellen/ändern (Admin)
|
||||
1. Formular: `pages/admin/task_form.php`
|
||||
2. Endpoint: `endpoints/admin/task_upsert.php`
|
||||
3. Service:
|
||||
- `TaskDefinitionService::createTask()` oder `updateTask()`
|
||||
- Speichert Definition + Regeln (`TaskRuleRepository::replaceTaskRules`)
|
||||
- Schreibt Audit-Eintrag (`task_change_log`)
|
||||
4. Danach Materialisierung:
|
||||
- `AssignmentResolverService::materializeAssignmentsForTask()`
|
||||
|
||||
### 2) Zuweisungen materialisieren
|
||||
`AssignmentResolverService`:
|
||||
- Lädt aktive Aufgabe + Rhythmus-Kontext.
|
||||
- Ermittelt Zielkontakte über `task_target_rule` und `task_target_override`.
|
||||
- Erzeugt/aktualisiert `task_assignment` mit `cycle_key`, `cycle_start_at`, `cycle_end_at`, `due_at`.
|
||||
- Modus `calendar`: wie bisher zykluskalenderbasiert.
|
||||
- Modus `completion`: pro Mitarbeiter individueller Folgeturnus ab letzter Erledigung (ohne Vorziehen bei früher Erledigung).
|
||||
|
||||
Zusätzlicher Sammel-Endpoint:
|
||||
- `endpoints/admin/materialize_assignments.php` (POST/CLI): für alle aktiven Aufgaben.
|
||||
|
||||
### 3) Nutzer bearbeitet Aufgabe
|
||||
User-Endpunkte:
|
||||
- `endpoints/user/submit_read.php`
|
||||
- `endpoints/user/submit_form.php`
|
||||
- `endpoints/user/submit_quiz.php`
|
||||
- `endpoints/user/request_manual_approval.php`
|
||||
|
||||
`CompletionService`:
|
||||
- validiert Assignment + Methode + Berechtigung,
|
||||
- legt `task_submission` an,
|
||||
- entscheidet danach über Assignment-Fortschritt (`finalizeAssignmentState`).
|
||||
|
||||
### 4) Freigabeprozess
|
||||
Bei manueller Freigabe:
|
||||
- Submission wird mit `awaiting_approval` angelegt.
|
||||
- Admin entscheidet über `endpoints/admin/approval_decision.php`.
|
||||
- `ApprovalService::approveSubmission()` -> `CompletionService::refreshAssignmentAfterReview()`.
|
||||
- `ApprovalService::rejectSubmission()` -> Assignment wird `rejected`.
|
||||
|
||||
### 5) Abschluss und Zertifikat
|
||||
Wenn erforderliche Methoden erfüllt sind (`fulfillment_mode = all|any`):
|
||||
- Assignment wird `completed`.
|
||||
- `CertificateService::issueForAssignment()` erstellt Zertifikat, falls aktiviert.
|
||||
- Bei Zertifikatsmodus `cycle` gilt:
|
||||
- `valid_until = assignment.cycle_end_at`
|
||||
- dadurch ist die Gültigkeit automatisch an die gewählte Rhythmus-Basis gekoppelt.
|
||||
|
||||
## Rhythmus-Basis bei wiederkehrenden Aufgaben
|
||||
- `calendar`:
|
||||
- Zyklus folgt festen Kalenderintervallen ab `start_date`.
|
||||
- `completion`:
|
||||
- pro Mitarbeiter individuell,
|
||||
- neue Periode erst nach `completed_at`,
|
||||
- kein Vorziehen bei früher Erledigung,
|
||||
- erste Periode für neu zugewiesene Mitarbeiter startet am Zuweisungstag (bzw. nicht vor `start_date`).
|
||||
|
||||
Hinweis fürs Admin-Monitoring:
|
||||
- Für `completion` zeigt das Dashboard den aktuellen Stand je Mitarbeiter (ohne Cycle-Key-Auswahl).
|
||||
|
||||
### 6) Überfälligkeit und Eskalation
|
||||
`EscalationService::recalculateEscalations()`:
|
||||
- setzt bei überfälligen Assignments `status = overdue`,
|
||||
- berechnet Eskalationsstufe nach `task_reminder_policy`:
|
||||
- `first_escalation_days`
|
||||
- `step_days`
|
||||
- `max_level`
|
||||
- schreibt Events nach `task_escalation_event`.
|
||||
|
||||
Aktueller Betriebsmodus:
|
||||
- Recalc läuft nur bei explizitem Trigger (Admin-Button oder Endpoint):
|
||||
- `actions/admin/escalation_dashboard_action.php` via `?recalc=1`
|
||||
- `endpoints/admin/recalculate_escalations.php` (POST/CLI)
|
||||
- Für Automatik ist ein externer Job (z. B. Cron) nötig.
|
||||
|
||||
### 7) Reset / Löschen
|
||||
#### Reset aktueller Zyklus
|
||||
`TaskDefinitionService::resetCurrentCycleProgress()`:
|
||||
- widerruft Zertifikate (`task_certificate.status = revoked`),
|
||||
- löscht Einreichungen,
|
||||
- setzt Assignments zurück (`status=open`, `overdue_at=NULL`, `escalation_level=0`, `last_escalated_at=NULL`),
|
||||
- schreibt Audit-Eintrag `reset_cycle_progress`.
|
||||
|
||||
Hinweis:
|
||||
- Wenn `due_at` weiterhin in der Vergangenheit liegt, kann eine spätere Eskalations-Neuberechnung wieder `overdue`/Eskalation setzen.
|
||||
|
||||
#### Soft Delete
|
||||
- Endpoint: `endpoints/admin/task_delete.php`
|
||||
- Wirkung: Aufgabe deaktivieren (`active = 0`), Daten bleiben erhalten.
|
||||
|
||||
#### Hard Delete
|
||||
- Endpoint: `endpoints/admin/task_delete_hard.php`
|
||||
- Wirkung: vollständiges Löschen inkl. abhängiger Datensätze (per FK-Cascade).
|
||||
|
||||
## Audit / Nachvollziehbarkeit
|
||||
`task_change_log` wird derzeit für folgende Actions geschrieben:
|
||||
- `create`
|
||||
- `update`
|
||||
- `reset_cycle_progress`
|
||||
- `soft_delete`
|
||||
- `hard_delete`
|
||||
|
||||
Payloads:
|
||||
- `before_json`, `after_json`, `diff_json`
|
||||
- Zugriff über `TaskChangeLogRepository` (derzeit ohne dedizierte Admin-UI).
|
||||
|
||||
## Views und Routen
|
||||
Routen-Mapping in `bootstrap.php` (`task_cert_view_url`):
|
||||
- Admin:
|
||||
- `admin-task-list`
|
||||
- `edit`
|
||||
- `view`
|
||||
- `approvals`
|
||||
- `escalations`
|
||||
- `categories`
|
||||
- User:
|
||||
- `my-tasks`
|
||||
- `task`
|
||||
- `overdue-widget`
|
||||
|
||||
## CLI/Cron Beispiele
|
||||
Alle Beispiele gehen davon aus, dass der Job im Modul-Verzeichnis ausgeführt wird:
|
||||
`/tasks`
|
||||
|
||||
### Cron-CLI Jobs
|
||||
#### 1) Assignments materialisieren
|
||||
```bash
|
||||
php cli/task_cert_materialize_assignments.php
|
||||
```
|
||||
|
||||
Optional:
|
||||
- `--no-lock` (nur Debug)
|
||||
- `--json` (Summary zusätzlich auf stdout)
|
||||
|
||||
Operation ist idempotent (kein --apply nötig).
|
||||
|
||||
#### 2) Zuweisungen synchronisieren
|
||||
```bash
|
||||
php cli/task_cert_sync_assignments.php --apply
|
||||
```
|
||||
|
||||
Optional:
|
||||
- `--dry-run` (keine Änderungen schreiben)
|
||||
- `--task_id=123` (nur eine Aufgabe)
|
||||
- `--no-lock` (nur Debug)
|
||||
- `--json` (Summary zusätzlich auf stdout)
|
||||
|
||||
#### 3) Eskalationen neu berechnen
|
||||
```bash
|
||||
php cli/task_cert_recalculate_escalations.php --apply
|
||||
```
|
||||
|
||||
Optional:
|
||||
- `--dry-run` (keine Änderungen schreiben)
|
||||
- `--task_id=123` (nur eine Aufgabe)
|
||||
- `--no-lock` (nur Debug)
|
||||
- `--json` (Summary zusätzlich auf stdout)
|
||||
|
||||
### Exit-Codes der CLI Jobs
|
||||
- `0`: Erfolg
|
||||
- `1`: Lauf mit Fehler beendet
|
||||
- `2`: Ungültige CLI-Parameter
|
||||
- `3`: Lauf übersprungen (Lock bereits aktiv)
|
||||
|
||||
### CLI-Ausgabe
|
||||
Jeder Lauf schreibt eine Zeile auf stdout bzw. stderr:
|
||||
- `[OK] sync_assignments (apply) abgeschlossen in 312ms.`
|
||||
- `[SKIP] sync_assignments: Lock aktiv, Job wird übersprungen.`
|
||||
- `[ERROR] sync_assignments (apply): Fehlermeldung`
|
||||
|
||||
### Locking und Cron-Log
|
||||
- Lockfiles:
|
||||
- `logs/locks/task_cert_materialize_assignments.lock`
|
||||
- `logs/locks/task_cert_sync_assignments.lock`
|
||||
- `logs/locks/task_cert_recalculate_escalations.lock`
|
||||
- Separates Cron-Log (JSON-Lines):
|
||||
- `logs/task_cert_cron.log`
|
||||
|
||||
### Cron-Empfehlung
|
||||
```cron
|
||||
# Assignments materialisieren: alle 15 Minuten
|
||||
*/15 * * * * cd /tasks && php cli/task_cert_materialize_assignments.php
|
||||
# Eskalationen berechnen: alle 30 Minuten
|
||||
*/30 * * * * cd /tasks && php cli/task_cert_recalculate_escalations.php --apply
|
||||
# Zuweisungs-Sync: täglich um 02:00
|
||||
0 2 * * * cd /tasks && php cli/task_cert_sync_assignments.php --apply
|
||||
```
|
||||
|
||||
### Legacy-Endpoints (nur noch als HTTP-Endpoints für Admin-UI)
|
||||
- `endpoints/admin/materialize_assignments.php`
|
||||
- `endpoints/admin/sync_assignments.php`
|
||||
- `endpoints/admin/recalculate_escalations.php`
|
||||
|
||||
Diese sollten **nicht mehr per Cron** aufgerufen werden (kein Locking, keine CLI-Ausgabe).
|
||||
|
||||
## Betriebsempfehlungen
|
||||
1. Für produktiven Betrieb einen periodischen Recalc-Job einrichten:
|
||||
- `cli/task_cert_sync_assignments.php`
|
||||
- `cli/task_cert_recalculate_escalations.php`
|
||||
2. Zeitzonen konsistent halten (Schema nutzt UTC-Default).
|
||||
3. Vor Migrationen Backup einplanen (insb. Kategorie- und Lifecycle-Migrationen).
|
||||
4. Bei UI-Entscheidungen klar trennen:
|
||||
- „Überfällig“ vs. „Eskalation“ (nicht dasselbe).
|
||||
|
||||
## Schnelle Troubleshooting-Checks
|
||||
- Eskalation fehlt:
|
||||
- `reminder_active = 1`?
|
||||
- `due_at` gesetzt und in Vergangenheit?
|
||||
- Recalc ausgelöst?
|
||||
- In Eskalationsliste „zu viel“ sichtbar:
|
||||
- Prüfen, ob Filter auf `escalation_level > 0` gesetzt ist.
|
||||
- Reset scheinbar ohne Effekt:
|
||||
- Prüfen, ob `due_at` weiterhin alt ist und Recalc erneut `overdue` setzt.
|
||||
|
||||
## Neue Features (Stand 2026-03-18)
|
||||
|
||||
### Karenzzeit (Grace Period)
|
||||
- **Feld:** `task_reminder_policy.grace_days` (Default: 7)
|
||||
- **Feld:** `task_assignment.grace_until` (berechnet bei Erstellung: `now() + grace_days`)
|
||||
- **Logik:** Eskalation greift erst wenn `now > max(due_at, grace_until)`
|
||||
- **Zweck:** Verhindert sofortige Eskalation bei Späteinsteigern im Zyklus
|
||||
- **Migration:** `sql/migrations/2026-03-18_add_grace_period.sql`
|
||||
|
||||
### Formular-Vorbefüllung (Prefill)
|
||||
- **Feld:** `task_form_field.prefill_column` (varchar, nullable)
|
||||
- **Whitelist:** `lib/ContactPrefillColumns.php` definiert erlaubte `main_contact`-Spalten
|
||||
- **Aktuell erlaubt:** `name`, `email`, `vehicle_registration_place`
|
||||
- **Verhalten:** Bei Typ `text` wird der Wert aus dem Kontakt des Nutzers als Default gesetzt (überschreibbar)
|
||||
- **Migration:** `sql/migrations/2026-03-18_add_form_field_prefill.sql`
|
||||
|
||||
### Verantwortliche (Responsible Contacts)
|
||||
- **Tabelle:** `task_responsible` (N:M zwischen `task_definition` und `main_contact`)
|
||||
- **Admin:** Multiselect im Stammdaten-Tab
|
||||
- **User:** Ansprechpartner-Section im Aufgaben-Detail (Name + E-Mail)
|
||||
- **Migration:** `sql/migrations/2026-03-17_add_task_responsible.sql`
|
||||
|
||||
### Completion-Guard
|
||||
- Backend: `CompletionService::validateContext()` blockiert Einreichungen bei `status = completed|expired`
|
||||
- Frontend: Alle Submit-Buttons disabled bei abgeschlossenen Assignments
|
||||
|
||||
### Sofortige Folgezyklus-Erstellung
|
||||
- Bei erledigungsbasierten Aufgaben erstellt `CompletionService::finalizeAssignmentState()` nach Completion direkt den nächsten Zyklus
|
||||
- Deduplizierung: `createIfNotExists` + DB Unique-Key verhindert Doppel-Erstellung durch Cron
|
||||
|
||||
### Cron-Heartbeat-Monitoring
|
||||
- `lib/CronHeartbeat.php`: Schreibt JSON-Dateien nach `logs/heartbeat/` bei erfolgreichem Cron-Lauf
|
||||
- Admin-Aufgabenliste zeigt letzten erfolgreichen Lauf pro Job
|
||||
- Jobs: `materialize_assignments`, `recalculate_escalations`, `sync_assignments`
|
||||
|
||||
### Einreichungs-Historie
|
||||
- User sieht alle bisherigen Einreichungen (nicht nur die letzte) im Aufgaben-Detail
|
||||
- Neueste Einreichung direkt sichtbar, ältere aufklappbar
|
||||
- Formular-Antworten formatiert als Key-Value-Liste (nicht JSON)
|
||||
|
||||
### Lesestatus-Persistenz
|
||||
- Bei Wissensbeiträgen: bereits gelesene Beiträge bleiben im Select vorausgewählt
|
||||
- Grüner "Gelesen"-Badge neben dem Beitragstitel
|
||||
|
||||
### Admin-Monitoring Übersicht
|
||||
- Fortschrittsbalken statt 8 KPI-Kacheln
|
||||
- Handlungsbedarf-Cards nur bei Überfällig/Freigaben/Eskalationen
|
||||
- Rhythmus-Badge (Kalender/Erledigungsbasiert) in Task-Liste und Detail-Header
|
||||
|
||||
### Mitarbeiter-Aufgabensuche (Admin)
|
||||
- Kontakt-Dropdown in der Admin-Aufgabenliste
|
||||
- Zeigt alle Assignments des gewählten Mitarbeiters mit Status, Daten, Zyklus
|
||||
- Default: Erster Kontakt vorausgewählt
|
||||
|
||||
## Utility-Klassen
|
||||
|
||||
### `lib/ContactPrefillColumns.php`
|
||||
Zentrale Whitelist für vorbefüllbare Kontaktspalten.
|
||||
- `MAP`: Spaltenname → Label (für Admin-Dropdown)
|
||||
- `sanitize()`: Validiert gegen Whitelist
|
||||
- `resolve()`: Holt Wert aus Kontakt-Array
|
||||
- Erweiterbar: Neue Spalten in `MAP` ergänzen
|
||||
|
||||
### `lib/CronHeartbeat.php`
|
||||
Dateibasiertes Monitoring für Cron-Jobs.
|
||||
- `record($jobName, $summary)`: Schreibt `logs/heartbeat/{job}.json`
|
||||
- `readAll()`: Liest alle Heartbeats für Admin-Anzeige
|
||||
- Kein DB-Schema nötig
|
||||
29
module/tasks/doc/task-cert-test-cases.md
Normal file
29
module/tasks/doc/task-cert-test-cases.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Task-Cert: Test-Cases (V1)
|
||||
|
||||
Diese Tabelle ist als schnelle Abnahme- und Regression-Checkliste gedacht.
|
||||
|
||||
| # | Praxisfall (kurz) | Typ | Konfiguration (Beispiel) | Testschritte | Erwartetes Ergebnis |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Erstunterweisung Arbeitssicherheit | Einmalig | Startdatum `2026-03-01`, Fristregel `Zyklusende`, Zertifikat `aus` | Aufgabe anlegen, Zuweisung materialisieren | Genau eine Zuweisung mit `cycle_key=once`, Status `open`, kein Zertifikat |
|
||||
| 2 | Einmaliger Pflicht-Read mit Frist | Einmalig | Startdatum `2026-03-01`, Fristregel `X Tage nach Startdatum`, `5` Tage, Zertifikat `aus` | Aufgabe anlegen, Zuweisung prüfen | `due_at = 2026-03-06 23:59:59` (tagesgenau je Implementierung), Status nicht `completed` |
|
||||
| 3 | Monatliche Hygiene-Schulung | Wiederkehrend (kalenderbasiert) | `monthly`, Intervall `1`, Startdatum `2026-01-01`, Frist `+5 Tage`, Zertifikat `Zyklus` | Aufgabe anlegen, Abschluss im Januar durchführen, nächste Periode prüfen | Februar-Periode startet kalenderbasiert, neue Frist wieder `+5 Tage`; Zertifikat Januar gültig bis Ende Januar-Zyklus |
|
||||
| 4 | Wöchentliche Team-Checkliste | Wiederkehrend (kalenderbasiert) | `weekly`, Intervall `1`, Startdatum `2026-03-02` (Montag), Fristregel `Wochentag=5` (Freitag) | Aufgabe anlegen, aktuelle Woche prüfen | `due_at` liegt auf Freitag derselben Zykluswoche |
|
||||
| 5 | Geräteeinweisung mit individuellem Rhythmus | Wiederkehrend (erledigungsbasiert) | `monthly`, Basis `completion`, Startdatum `2026-01-01`, Frist `+5 Tage` | Periode 1 früh am `2026-01-10` abschließen | Nächster Start wird **nicht vorgezogen**; Start bleibt mindestens beim geplanten Intervallstart |
|
||||
| 6 | Verspätete Nachschulung | Wiederkehrend (erledigungsbasiert) | `monthly`, Basis `completion`, Startdatum `2026-01-01`, Frist `+5 Tage` | Periode 1 spät am `2026-02-20` abschließen | Nächste Periode startet ab Abschlussanker (`2026-02-20`), also nach hinten verschoben |
|
||||
| 7 | Blockierende Periode ohne Abschluss | Wiederkehrend (erledigungsbasiert) | wie Fall 5 | Periode 1 **nicht** abschließen, Materialisierung erneut ausführen | Keine neue Periode wird angelegt, solange letzte Periode nicht `completed` ist |
|
||||
| 8 | Zertifikat 30 Tage gültig | Zertifikat (tagebasiert) | Zertifikat `an`, Validität `days=30` | Aufgabe abschließen, Zertifikat prüfen | Zertifikat wird ausgestellt und `valid_until` liegt 30 Tage nach Ausstellung |
|
||||
| 9 | Dauerzertifikat bei Wiederholung | Zertifikat (dauerhaft) | Zertifikat `an`, Validität `permanent`, wiederkehrende Aufgabe | Zwei Perioden nacheinander abschließen | Älteres Zertifikat bleibt gültig (`valid_until` leer/NULL), neues Zertifikat kommt zusätzlich dazu |
|
||||
| 10 | Einzelfall korrigieren im Admin | Admin Einzel-Reset (Erledigt-Tab) | Abgeschlossene Zuweisung mit Einreichung + Zertifikat vorhanden | In `Erledigt` Aktion `Zurücksetzen` ausführen | Für diese Assignment-ID: Einreichungen gelöscht, Zertifikat widerrufen, Zuweisung wieder `open`, Erfolgsmeldung im Tab |
|
||||
| 11 | Aufgabe erst ab Startdatum sichtbar | Sichtbarkeit/Startdatum | Startdatum in Zukunft, z. B. `2026-08-01` | Vor und am Startdatum im User-View prüfen | Vor Startdatum keine sichtbare offene Zuweisung; ab Startdatum sichtbar |
|
||||
| 12 | Mitarbeiter wechselt Abteilung | Zielgruppen-Sync | Zielregel z. B. Abteilung A; Nutzer wechselt von A nach B | `Zuweisungsregeln anwenden` (dry-run + apply) | Nutzer ohne Match wird entfernt, neue Treffer werden angelegt; Include-Override bleibt erhalten |
|
||||
| 13 | Reminder ab Tag 0 | Eskalation/Reminder | `first_escalation_days=0`, `step_days=7`, Reminder aktiv | Überfällige Assignment neu berechnen | Erste Eskalationsstufe greift sofort bei Überfälligkeit (ohne 1 Tag Wartezeit) |
|
||||
| 14 | Manuelle Freigabe mit Ablehnung | Freigabeprozess | Methode `manual_approval`, Submission offen | Im Freigaben-Tab zuerst `Ablehnen`, danach erneut einreichen und `Freigeben` | Statuswechsel korrekt: `awaiting_approval -> rejected -> approved/completed`, Zertifikat erst nach finaler Freigabe |
|
||||
| 15 | Zykluswechsel mit altem Key | Admin-Monitoring (kalenderbasiert) | Aufgabe mit >24 historischen Zyklen, URL enthält gültigen alten `cycle_key` | `view?id=<task_id>&cycle_key=<alter_key>` aufrufen | Angeforderter Zyklus bleibt selektiert, KPIs/Tabellen laden Daten genau dieses Zyklus |
|
||||
| 16 | Antworten exportieren (Zyklus) | Admin-Monitoring Export | Kalenderbasierte Aufgabe mit Einreichungen im gewählten Zyklus | In `view` Zyklus wählen, Button `Antworten exportieren` klicken | Download startet als CSV (`;`, UTF-8 BOM), eine Zeile pro Einreichung inkl. `cycle_key`, Kontakt, Methode, Status, Zeit, Punkte, `answer_summary`, `answer_detail_json` |
|
||||
| 17 | Antworten exportieren (Snapshot) | Admin-Monitoring Export | Erledigungsbasierte Aufgabe (`recurrence_basis=completion`) mit aktuellen Einreichungen | In `view` Button `Antworten exportieren` klicken | Download startet als Snapshot-CSV (latest-Stand), ohne Zyklus-Dropdown, aber mit Antworten der aktuellen Latest-Zuweisungen |
|
||||
|
||||
## Hinweise zur Durchführung
|
||||
|
||||
- Vor jedem Testfall mit festen Startwerten arbeiten (eigene Testaufgabe je Fall).
|
||||
- Bei wiederkehrenden Fällen Materialisierung und Eskalations-Recalc gezielt auslösen.
|
||||
- Für reproduzierbare Ergebnisse mit festen Datumswerten testen (keine relativen Angaben wie "heute").
|
||||
195
module/tasks/docs/cronjobs.md
Normal file
195
module/tasks/docs/cronjobs.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Cronjobs — Task-Modul
|
||||
|
||||
Das Task-Modul benötigt **drei Cronjobs**, die regelmäßig laufen müssen:
|
||||
|
||||
1. **Assignments materialisieren** — erstellt neue Aufgaben-Zuweisungen für aktive Zyklen
|
||||
2. **Assignments synchronisieren** — gleicht Zuweisungen mit aktuellen Zielgruppen-Regeln ab
|
||||
3. **Eskalationen berechnen** — markiert überfällige Aufgaben und erhöht Eskalationsstufen
|
||||
|
||||
Alle CLI-Skripte liegen in `cli/` und nutzen `CronRuntime` mit:
|
||||
- **Locking** (verhindert parallele Läufe)
|
||||
- **Strukturiertes Log** (`logs/task_cert_cron.log`, JSON-Lines)
|
||||
- **Heartbeat** (`logs/heartbeat/{job}.json` für Admin-UI)
|
||||
- **Exit-Codes** (0 = OK, 1 = Fehler, 2 = ungültige Parameter, 3 = Lock aktiv)
|
||||
- **CLI-Ausgabe** (`[OK]`/`[SKIP]`/`[ERROR]` auf stdout/stderr)
|
||||
|
||||
---
|
||||
|
||||
## 1. Assignments materialisieren
|
||||
|
||||
Erstellt für alle aktiven Aufgaben die Zuweisungen im aktuellen Zyklus (z.B. neuer Monat = neue Assignments).
|
||||
|
||||
```bash
|
||||
php cli/task_cert_materialize_assignments.php
|
||||
```
|
||||
|
||||
**Optionen:**
|
||||
- `--no-lock` — Locking deaktivieren (nur Debug)
|
||||
- `--json` — JSON-Ausgabe zusätzlich auf stdout
|
||||
- `--help` — Hilfe anzeigen
|
||||
|
||||
**Empfohlenes Intervall:** alle 15 Minuten
|
||||
|
||||
**Was passiert:**
|
||||
- Ermittelt den aktuellen Zyklus jeder aktiven Aufgabe (Tag/Woche/Monat/Jahr)
|
||||
- Erstellt fehlende Assignments für alle Zielkontakte
|
||||
- Bei completion-based Tasks: erstellt den nächsten Zyklus nach Abschluss
|
||||
- Operation ist idempotent (kein --apply nötig)
|
||||
|
||||
---
|
||||
|
||||
## 2. Assignments synchronisieren
|
||||
|
||||
Gleicht bestehende Assignments mit den aktuellen Zielgruppen-Regeln ab. Nützlich wenn sich Abteilungszugehörigkeiten oder Rollen ändern.
|
||||
|
||||
```bash
|
||||
# Alle aktiven Aufgaben (Anwenden)
|
||||
php cli/task_cert_sync_assignments.php --apply
|
||||
|
||||
# Dry-Run (nur Vorschau)
|
||||
php cli/task_cert_sync_assignments.php --dry-run
|
||||
|
||||
# Einzelne Aufgabe
|
||||
php cli/task_cert_sync_assignments.php --apply --task_id=42
|
||||
```
|
||||
|
||||
**Optionen:**
|
||||
- `--apply` — Mutierender Lauf (Zuweisungen anlegen/entfernen)
|
||||
- `--dry-run` — Nur Vorschau, keine DB-Änderungen
|
||||
- `--task_id=<id>` — Optional: nur eine Aufgabe verarbeiten
|
||||
- `--no-lock` — Locking deaktivieren (nur Debug)
|
||||
- `--json` — JSON-Ausgabe zusätzlich auf stdout
|
||||
- `--help` — Hilfe anzeigen
|
||||
|
||||
**Empfohlenes Intervall:** täglich nachts (z.B. 02:00)
|
||||
|
||||
**Was passiert:**
|
||||
- Vergleicht Soll-Zielgruppe (Regeln + Overrides) mit Ist-Zuweisungen
|
||||
- Erstellt fehlende Assignments, entfernt veraltete
|
||||
- Schützt abgeschlossene Assignments und solche mit Zertifikaten vor Löschung
|
||||
|
||||
---
|
||||
|
||||
## 3. Eskalationen berechnen
|
||||
|
||||
Prüft alle offenen Assignments auf Überfälligkeit und berechnet Eskalationsstufen.
|
||||
|
||||
```bash
|
||||
# Alle Aufgaben (Anwenden)
|
||||
php cli/task_cert_recalculate_escalations.php --apply
|
||||
|
||||
# Dry-Run
|
||||
php cli/task_cert_recalculate_escalations.php --dry-run
|
||||
|
||||
# Einzelne Aufgabe
|
||||
php cli/task_cert_recalculate_escalations.php --apply --task_id=42
|
||||
```
|
||||
|
||||
**Optionen:**
|
||||
- `--apply` — Mutierender Lauf (Status/Eskalationslevel schreiben)
|
||||
- `--dry-run` — Nur Vorschau, keine DB-Änderungen
|
||||
- `--task_id=<id>` — Optional: nur eine Aufgabe verarbeiten
|
||||
- `--no-lock` — Locking deaktivieren (nur Debug)
|
||||
- `--json` — JSON-Ausgabe zusätzlich auf stdout
|
||||
- `--help` — Hilfe anzeigen
|
||||
|
||||
**Empfohlenes Intervall:** alle 30 Minuten
|
||||
|
||||
**Was passiert:**
|
||||
- Prüft `due_at` aller offenen/laufenden Assignments
|
||||
- Setzt `status = 'overdue'` wenn Deadline überschritten
|
||||
- Berechnet `escalation_level` anhand der Erinnerungsrichtlinie:
|
||||
- `first_escalation_days`: Tage nach Fälligkeit bis erste Eskalation
|
||||
- `step_days`: Tage zwischen Eskalationsstufen
|
||||
- `max_level`: Maximale Eskalationsstufe
|
||||
- Protokolliert Eskalations-Events in `task_escalation_event`
|
||||
- Setzt Eskalation zurück wenn Aufgabe nicht mehr überfällig ist
|
||||
|
||||
---
|
||||
|
||||
## Crontab-Einrichtung
|
||||
|
||||
```crontab
|
||||
# Assignments materialisieren — alle 15 Minuten
|
||||
*/15 * * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_materialize_assignments.php >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
|
||||
|
||||
# Eskalationen berechnen — alle 30 Minuten
|
||||
*/30 * * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_recalculate_escalations.php --apply >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
|
||||
|
||||
# Assignments synchronisieren — täglich um 02:00
|
||||
0 2 * * * cd /var/www/intranet/html/module/tasks && /usr/bin/php cli/task_cert_sync_assignments.php --apply >> /var/www/intranet/html/module/tasks/logs/task_cert_cron.log 2>&1
|
||||
```
|
||||
|
||||
> **Hinweis:** Den Pfad `/var/www/intranet/html/module/tasks` ggf. an die tatsächliche Installation anpassen. PHP-Binary-Pfad mit `which php` prüfen.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
Wenn alle Jobs gleichzeitig laufen sollen, ist die empfohlene Reihenfolge:
|
||||
|
||||
1. `task_cert_materialize_assignments.php` — erst Assignments anlegen
|
||||
2. `task_cert_sync_assignments.php --apply` — dann synchronisieren
|
||||
3. `task_cert_recalculate_escalations.php --apply` — dann Eskalationen berechnen
|
||||
|
||||
Bei getrennten Intervallen (wie oben) ist die Reihenfolge unkritisch.
|
||||
|
||||
---
|
||||
|
||||
## Exit-Codes
|
||||
|
||||
| Code | Bedeutung |
|
||||
|------|-----------|
|
||||
| 0 | Erfolg |
|
||||
| 1 | Fehler (Exception) |
|
||||
| 2 | Ungültige CLI-Parameter |
|
||||
| 3 | Lock aktiv, Job übersprungen |
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
### CLI-Ausgabe (stdout/stderr)
|
||||
Jeder Lauf schreibt eine Zeile auf stdout bzw. stderr:
|
||||
- `[OK] sync_assignments (apply) abgeschlossen in 312ms.`
|
||||
- `[SKIP] sync_assignments: Lock aktiv, Job wird übersprungen.`
|
||||
- `[ERROR] sync_assignments (apply): DB-Verbindung fehlgeschlagen`
|
||||
|
||||
Bei `--json` wird stattdessen ein JSON-Objekt auf stdout ausgegeben.
|
||||
|
||||
### Strukturiertes Log
|
||||
Alle Läufe (Erfolg, Fehler, Skip) werden in `logs/task_cert_cron.log` als JSON-Lines geschrieben — unabhängig von der Crontab-Umleitung.
|
||||
|
||||
### Locking
|
||||
Lockfiles liegen in `logs/locks/`:
|
||||
- `task_cert_materialize_assignments.lock`
|
||||
- `task_cert_sync_assignments.lock`
|
||||
- `task_cert_recalculate_escalations.lock`
|
||||
|
||||
---
|
||||
|
||||
## Monitoring (Heartbeat)
|
||||
|
||||
Jeder erfolgreiche Apply-Lauf schreibt einen Timestamp nach `logs/heartbeat/{job}.json`.
|
||||
|
||||
**Status prüfen:**
|
||||
```bash
|
||||
cat logs/heartbeat/materialize_assignments.json
|
||||
cat logs/heartbeat/recalculate_escalations.json
|
||||
cat logs/heartbeat/sync_assignments.json
|
||||
```
|
||||
|
||||
**Admin-Ansicht:** Die Admin-Aufgabenliste zeigt automatisch den letzten erfolgreichen Lauf jedes Jobs am unteren Rand an. Wenn ein Job noch nie gelaufen ist, wird dies rot markiert.
|
||||
|
||||
**Hinweis:** Heartbeat-Dateien werden nur bei **erfolgreichem** Lauf geschrieben. Fehler sind am Ausbleiben des Timestamps erkennbar.
|
||||
|
||||
---
|
||||
|
||||
## Legacy-Endpoints
|
||||
|
||||
Die alten Endpoint-Skripte (`endpoints/admin/`) bleiben als HTTP-Endpoints für die Admin-UI erhalten, sollten aber **nicht mehr per Cron aufgerufen** werden:
|
||||
- `endpoints/admin/materialize_assignments.php`
|
||||
- `endpoints/admin/sync_assignments.php`
|
||||
- `endpoints/admin/recalculate_escalations.php`
|
||||
|
||||
Diese haben kein Locking und keine strukturierte CLI-Ausgabe.
|
||||
68
module/tasks/endpoints/admin/approval_decision.php
Normal file
68
module/tasks/endpoints/admin/approval_decision.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
|
||||
}
|
||||
|
||||
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
|
||||
$isJson = strpos($contentType, 'application/json') !== false;
|
||||
|
||||
if ($isJson) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$body = json_decode($rawBody ?: '{}', true);
|
||||
if (!is_array($body)) {
|
||||
$body = [];
|
||||
}
|
||||
|
||||
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
|
||||
if ($csrfToken !== '') {
|
||||
$_POST['csrf_token'] = $csrfToken;
|
||||
}
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$submissionId = (int) ($body['submission_id'] ?? 0);
|
||||
$decision = trim((string) ($body['decision'] ?? ''));
|
||||
$note = trim((string) ($body['note'] ?? ''));
|
||||
} else {
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
$submissionId = TaskCertRequest::postInt('submission_id', 0);
|
||||
$decision = TaskCertRequest::postString('decision', '');
|
||||
$note = TaskCertRequest::postString('note', '');
|
||||
}
|
||||
|
||||
if ($submissionId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige submission_id'], 400);
|
||||
}
|
||||
|
||||
if (!in_array($decision, ['approve', 'reject'], true)) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige decision'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
|
||||
if ($decision === 'approve') {
|
||||
$services['approvalService']->approveSubmission($submissionId, $auth->contactId(), $note);
|
||||
} else {
|
||||
$services['approvalService']->rejectSubmission($submissionId, $auth->contactId(), $note);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'submission_id' => $submissionId,
|
||||
'decision' => $decision,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
47
module/tasks/endpoints/admin/contact_search.php
Normal file
47
module/tasks/endpoints/admin/contact_search.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
$term = TaskCertRequest::queryString('term', '');
|
||||
$limit = TaskCertRequest::queryInt('limit', 15);
|
||||
|
||||
$services = task_cert_services();
|
||||
$lookupRepo = $services['lookupRepo'] ?? null;
|
||||
|
||||
if (!$lookupRepo instanceof TaskLookupRepository) {
|
||||
TaskCertResponse::json(['results' => []]);
|
||||
}
|
||||
|
||||
$contacts = $lookupRepo->searchContacts($term, $limit);
|
||||
|
||||
$results = [];
|
||||
foreach ($contacts as $c) {
|
||||
$id = (int) ($c['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($c['name'] ?? ''));
|
||||
$email = trim((string) ($c['email'] ?? ''));
|
||||
|
||||
$label = $name;
|
||||
if ($label === '') {
|
||||
$label = $email !== '' ? $email : 'Kontakt #' . $id;
|
||||
} elseif ($email !== '') {
|
||||
$label .= ' (' . $email . ')';
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
TaskCertResponse::json(['results' => $results]);
|
||||
99
module/tasks/endpoints/admin/download_submission_file.php
Normal file
99
module/tasks/endpoints/admin/download_submission_file.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (TaskCertRequest::method() !== 'GET') {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405, false);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
TaskCertResponse::error('Nicht eingeloggt', 401, false);
|
||||
}
|
||||
|
||||
$csrfToken = TaskCertRequest::queryString('csrf_token', '');
|
||||
if (!TaskCertCsrf::validate($csrfToken)) {
|
||||
TaskCertResponse::error('CSRF validation failed', 419, false);
|
||||
}
|
||||
|
||||
$submissionId = TaskCertRequest::queryInt('submission_id', 0);
|
||||
$fieldKey = TaskCertRequest::queryString('field_key', '');
|
||||
$index = TaskCertRequest::queryInt('index', -1);
|
||||
|
||||
if ($submissionId <= 0 || $fieldKey === '' || $index < 0) {
|
||||
TaskCertResponse::error('Ungültige Parameter.', 400, false);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$submission = $services['submissionRepo']->findById($submissionId);
|
||||
if ($submission === null) {
|
||||
TaskCertResponse::error('Einreichung nicht gefunden.', 404, false);
|
||||
}
|
||||
|
||||
// Zugriff: Admin ODER Eigentümer der Einreichung.
|
||||
$isAdmin = $auth->hasPermission('r-task-cert-admin');
|
||||
$isOwner = (int) ($submission['main_contact_id'] ?? 0) === $auth->contactId();
|
||||
if (!$isAdmin && !$isOwner) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403, false);
|
||||
}
|
||||
|
||||
$payload = is_array($submission['payload'] ?? null) ? $submission['payload'] : [];
|
||||
$formValues = is_array($payload['form_values'] ?? null) ? $payload['form_values'] : [];
|
||||
$fieldFiles = is_array($formValues[$fieldKey] ?? null) ? $formValues[$fieldKey] : [];
|
||||
$fileMeta = is_array($fieldFiles[$index] ?? null) ? $fieldFiles[$index] : null;
|
||||
|
||||
if ($fileMeta === null || !isset($fileMeta['file_path'])) {
|
||||
TaskCertResponse::error('Datei nicht gefunden.', 404, false);
|
||||
}
|
||||
|
||||
$absolutePath = TaskUploadStorage::absolutePath((string) $fileMeta['file_path']);
|
||||
if ($absolutePath === null || !is_file($absolutePath)) {
|
||||
TaskCertResponse::error('Datei nicht gefunden.', 404, false);
|
||||
}
|
||||
|
||||
$fileName = (string) ($fileMeta['file_name'] ?? 'datei');
|
||||
|
||||
// Content-Type IMMER aus der (erlaubten) Endung ableiten — nie aus dem
|
||||
// gespeicherten/Client-MIME (schützt auch Alt-Datensätze vor Stored-XSS,
|
||||
// z. B. als Bild getarntes SVG).
|
||||
$mime = TaskUploadPolicy::mimeForFilename($fileName);
|
||||
|
||||
// RFC-5987: ASCII-Fallback für alte Clients + UTF-8-URL-encoded für moderne.
|
||||
$asciiFallback = preg_replace('/[^\x20-\x7E]+/', '_', $fileName) ?: 'datei';
|
||||
|
||||
// Inline nur für eine feste, sichere MIME-Menge (kein SVG/HTML/XML),
|
||||
// alles andere als Download.
|
||||
$disposition = TaskUploadPolicy::isInlineSafeMime($mime) ? 'inline' : 'attachment';
|
||||
|
||||
if (!headers_sent()) {
|
||||
http_response_code(200);
|
||||
header('Content-Type: ' . $mime);
|
||||
header(sprintf(
|
||||
'Content-Disposition: %s; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$disposition,
|
||||
addcslashes($asciiFallback, '"\\'),
|
||||
rawurlencode($fileName)
|
||||
));
|
||||
header('Content-Length: ' . (int) filesize($absolutePath));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
readfile($absolutePath);
|
||||
exit;
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertLogger::error('Submission-Datei-Download fehlgeschlagen', [
|
||||
'submission_id' => $submissionId,
|
||||
'field_key' => $fieldKey,
|
||||
'index' => $index,
|
||||
'exception' => $throwable,
|
||||
]);
|
||||
TaskCertResponse::error('Download fehlgeschlagen.', 500, false);
|
||||
}
|
||||
196
module/tasks/endpoints/admin/export_answers_csv.php
Normal file
196
module/tasks/endpoints/admin/export_answers_csv.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_csv_safe_slug')) {
|
||||
function task_cert_csv_safe_slug(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9_-]+/', '-', $value) ?? '';
|
||||
$value = trim($value, '-_');
|
||||
return $value !== '' ? $value : 'export';
|
||||
}
|
||||
}
|
||||
|
||||
if (TaskCertRequest::method() !== 'GET') {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405, false);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403, false);
|
||||
}
|
||||
|
||||
$csrfToken = TaskCertRequest::queryString('csrf_token', '');
|
||||
if (!TaskCertCsrf::validate($csrfToken)) {
|
||||
TaskCertResponse::error('CSRF validation failed', 419, false);
|
||||
}
|
||||
|
||||
$taskId = TaskCertRequest::queryInt('id', 0);
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::error('Ungültige Aufgaben-ID.', 400, false);
|
||||
}
|
||||
|
||||
$queryMode = TaskCertRequest::queryString('mode', '');
|
||||
$queryCycleKey = TaskCertRequest::queryString('cycle_key', '');
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$taskService = $services['taskDefinitionService'] ?? null;
|
||||
$monitoringService = $services['taskMonitoringService'] ?? null;
|
||||
|
||||
if (!$taskService instanceof TaskDefinitionService) {
|
||||
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
|
||||
}
|
||||
if (!$monitoringService instanceof TaskMonitoringService) {
|
||||
throw new RuntimeException('TaskMonitoringService konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$task = $taskService->getTaskDetail($taskId);
|
||||
if ($task === null) {
|
||||
TaskCertResponse::error('Aufgabe nicht gefunden.', 404, false);
|
||||
}
|
||||
|
||||
$export = $monitoringService->buildAnswersExport($task, [
|
||||
'mode' => $queryMode,
|
||||
'cycle_key' => $queryCycleKey,
|
||||
]);
|
||||
|
||||
$mode = trim((string) ($export['mode'] ?? 'cycle'));
|
||||
if (!in_array($mode, ['cycle', 'snapshot'], true)) {
|
||||
$mode = 'cycle';
|
||||
}
|
||||
$selectedCycleKey = trim((string) ($export['cycle_key'] ?? ''));
|
||||
$rows = is_array($export['rows'] ?? null) ? $export['rows'] : [];
|
||||
$taskTitle = trim((string) ($task['title'] ?? 'Aufgabe'));
|
||||
|
||||
$filenameCyclePart = $mode === 'snapshot'
|
||||
? 'snapshot'
|
||||
: ('cycle-' . task_cert_csv_safe_slug($selectedCycleKey !== '' ? $selectedCycleKey : 'none'));
|
||||
$filename = sprintf(
|
||||
'task-answers-%d-%s-%s.csv',
|
||||
$taskId,
|
||||
$filenameCyclePart,
|
||||
date('Ymd_His')
|
||||
);
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$output = fopen('php://output', 'wb');
|
||||
if (!is_resource($output)) {
|
||||
throw new RuntimeException('CSV-Ausgabe konnte nicht initialisiert werden.');
|
||||
}
|
||||
|
||||
// UTF-8 BOM for Excel on German locale systems.
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
|
||||
$header = [
|
||||
'task_id',
|
||||
'task_title',
|
||||
'export_mode',
|
||||
'selected_cycle_key',
|
||||
'row_cycle_key',
|
||||
'submission_id',
|
||||
'assignment_id',
|
||||
'contact_id',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'method_type',
|
||||
'method_label',
|
||||
'submission_status',
|
||||
'assignment_status',
|
||||
'submitted_at',
|
||||
'due_at',
|
||||
'overdue_at',
|
||||
'completed_at',
|
||||
'escalation_level',
|
||||
'score',
|
||||
'max_score',
|
||||
'review_note',
|
||||
'answer_summary',
|
||||
'answer_detail_json',
|
||||
];
|
||||
fputcsv($output, $header, ';');
|
||||
|
||||
$toCsvCell = static function ($value): string {
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($value === false) {
|
||||
$value = '';
|
||||
}
|
||||
}
|
||||
|
||||
$stringValue = (string) $value;
|
||||
$trimmed = ltrim($stringValue);
|
||||
if ($trimmed !== '') {
|
||||
$firstChar = substr($trimmed, 0, 1);
|
||||
if (in_array($firstChar, ['=', '+', '-', '@'], true)) {
|
||||
$stringValue = "'" . $stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $stringValue;
|
||||
};
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = is_array($row) ? $row : [];
|
||||
$line = [
|
||||
$taskId,
|
||||
$taskTitle,
|
||||
$mode,
|
||||
$selectedCycleKey,
|
||||
(string) ($row['cycle_key'] ?? ''),
|
||||
(int) ($row['submission_id'] ?? 0),
|
||||
(int) ($row['assignment_id'] ?? 0),
|
||||
(int) ($row['contact_id'] ?? 0),
|
||||
(string) ($row['contact_name'] ?? ''),
|
||||
(string) ($row['contact_email'] ?? ''),
|
||||
(string) ($row['method_type'] ?? ''),
|
||||
(string) ($row['method_label'] ?? ''),
|
||||
(string) ($row['submission_status'] ?? ''),
|
||||
(string) ($row['assignment_status'] ?? ''),
|
||||
(string) ($row['submitted_at'] ?? ''),
|
||||
(string) ($row['due_at'] ?? ''),
|
||||
(string) ($row['overdue_at'] ?? ''),
|
||||
(string) ($row['completed_at'] ?? ''),
|
||||
(int) ($row['escalation_level'] ?? 0),
|
||||
$row['score'] !== null ? (int) $row['score'] : '',
|
||||
$row['max_score'] !== null ? (int) $row['max_score'] : '',
|
||||
(string) ($row['review_note'] ?? ''),
|
||||
(string) ($row['answer_summary'] ?? ''),
|
||||
(string) ($row['answer_detail_json'] ?? '{}'),
|
||||
];
|
||||
|
||||
$csvLine = array_map($toCsvCell, $line);
|
||||
fputcsv($output, $csvLine, ';');
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
} catch (Throwable $throwable) {
|
||||
$statusCode = $throwable instanceof InvalidArgumentException ? 400 : 500;
|
||||
TaskCertResponse::error($throwable->getMessage(), $statusCode, false);
|
||||
}
|
||||
98
module/tasks/endpoints/admin/mark_completed.php
Normal file
98
module/tasks/endpoints/admin/mark_completed.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
|
||||
}
|
||||
|
||||
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
|
||||
$isJson = strpos($contentType, 'application/json') !== false;
|
||||
|
||||
if ($isJson) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$body = json_decode($rawBody ?: '{}', true);
|
||||
if (!is_array($body)) {
|
||||
$body = [];
|
||||
}
|
||||
|
||||
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
|
||||
if ($csrfToken !== '') {
|
||||
$_POST['csrf_token'] = $csrfToken;
|
||||
}
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = (int) ($body['task_id'] ?? 0);
|
||||
$assignmentId = (int) ($body['assignment_id'] ?? 0);
|
||||
$adminNote = trim((string) ($body['admin_note'] ?? ''));
|
||||
$methodPayloads = [];
|
||||
$rawMethods = $body['method'] ?? [];
|
||||
if (is_array($rawMethods)) {
|
||||
foreach ($rawMethods as $methodId => $fields) {
|
||||
$methodId = (int) $methodId;
|
||||
if ($methodId <= 0 || !is_array($fields)) {
|
||||
continue;
|
||||
}
|
||||
$methodPayloads[$methodId] = $fields;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
$adminNote = TaskCertRequest::postString('admin_note', '');
|
||||
$methodPayloads = [];
|
||||
$rawMethods = $_POST['method'] ?? [];
|
||||
if (is_array($rawMethods)) {
|
||||
foreach ($rawMethods as $methodId => $fields) {
|
||||
$methodId = (int) $methodId;
|
||||
if ($methodId <= 0 || !is_array($fields)) {
|
||||
continue;
|
||||
}
|
||||
$methodPayloads[$methodId] = $fields;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
|
||||
}
|
||||
|
||||
if ($assignmentId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige assignment_id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$taskService = $services['taskDefinitionService'] ?? null;
|
||||
if (!$taskService instanceof TaskDefinitionService) {
|
||||
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$stats = $taskService->adminMarkCompleted(
|
||||
$taskId,
|
||||
$assignmentId,
|
||||
$auth->contactId(),
|
||||
$methodPayloads,
|
||||
$adminNote
|
||||
);
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'assignment_id' => $assignmentId,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
102
module/tasks/endpoints/admin/mark_completed_methods.php
Normal file
102
module/tasks/endpoints/admin/mark_completed_methods.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::json(['error' => 'Keine Berechtigung'], 403);
|
||||
}
|
||||
|
||||
$assignmentId = (int) ($_GET['assignment_id'] ?? 0);
|
||||
if ($assignmentId <= 0) {
|
||||
TaskCertResponse::json(['error' => 'Ungültige assignment_id'], 400);
|
||||
}
|
||||
|
||||
$services = task_cert_services();
|
||||
$assignment = $services['assignmentRepo']->findById($assignmentId);
|
||||
if ($assignment === null) {
|
||||
TaskCertResponse::json(['error' => 'Zuweisung nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
$taskId = (int) ($assignment['task_id'] ?? 0);
|
||||
$task = $services['definitionRepo']->findById($taskId);
|
||||
if ($task === null) {
|
||||
TaskCertResponse::json(['error' => 'Aufgabe nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
|
||||
$contact = null;
|
||||
if ($contactId > 0) {
|
||||
$contact = TaskCertDb::fetchOne(
|
||||
'SELECT id, name, email FROM main_contact WHERE id = ?',
|
||||
'i',
|
||||
[$contactId]
|
||||
);
|
||||
}
|
||||
|
||||
$methods = array_values(array_filter(
|
||||
$services['ruleRepo']->getMethodsByTask($taskId),
|
||||
static fn(array $m): bool => (int) ($m['active'] ?? 1) === 1
|
||||
));
|
||||
|
||||
$existingSubmissions = $services['submissionRepo']->listByAssignment($assignmentId);
|
||||
$fulfilledMethodIds = [];
|
||||
foreach ($existingSubmissions as $sub) {
|
||||
$subStatus = (string) ($sub['status'] ?? '');
|
||||
if (in_array($subStatus, ['auto_passed', 'approved'], true)) {
|
||||
$fulfilledMethodIds[(int) ($sub['method_id'] ?? 0)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$methodsOut = [];
|
||||
foreach ($methods as $method) {
|
||||
$methodId = (int) ($method['id'] ?? 0);
|
||||
$methodType = (string) ($method['method_type'] ?? '');
|
||||
|
||||
$formFields = [];
|
||||
if ($methodType === 'form_submit') {
|
||||
foreach ((array) ($method['form_fields'] ?? []) as $field) {
|
||||
$options = [];
|
||||
$rawOptions = is_array($field['options'] ?? null) ? $field['options'] : [];
|
||||
foreach ($rawOptions as $opt) {
|
||||
$options[] = [
|
||||
'value' => (string) ($opt['value'] ?? ''),
|
||||
'label' => (string) ($opt['label'] ?? $opt['value'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$formFields[] = [
|
||||
'field_key' => (string) ($field['field_key'] ?? ''),
|
||||
'label' => (string) ($field['label'] ?? ''),
|
||||
'field_type' => (string) ($field['field_type'] ?? 'text'),
|
||||
'is_required' => (int) ($field['is_required'] ?? 0),
|
||||
'options' => $options,
|
||||
'placeholder' => (string) ($field['placeholder'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$methodsOut[] = [
|
||||
'id' => $methodId,
|
||||
'method_type' => $methodType,
|
||||
'is_required' => (int) ($method['is_required'] ?? 1),
|
||||
'already_fulfilled' => isset($fulfilledMethodIds[$methodId]),
|
||||
'knowledge_post_count' => count((array) ($method['knowledge_posts'] ?? [])),
|
||||
'quiz_question_count' => count((array) ($method['quiz_questions'] ?? [])),
|
||||
'form_fields' => $formFields,
|
||||
];
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'task_id' => $taskId,
|
||||
'task_title' => (string) ($task['title'] ?? ''),
|
||||
'assignment_id' => $assignmentId,
|
||||
'contact_name' => (string) ($contact['name'] ?? '-'),
|
||||
'contact_email' => (string) ($contact['email'] ?? ''),
|
||||
'cycle_key' => (string) ($assignment['cycle_key'] ?? ''),
|
||||
'status' => (string) ($assignment['status'] ?? ''),
|
||||
'due_at' => (string) ($assignment['due_at'] ?? ''),
|
||||
'methods' => $methodsOut,
|
||||
'csrf_token' => TaskCertCsrf::token(),
|
||||
]);
|
||||
36
module/tasks/endpoints/admin/materialize_assignments.php
Normal file
36
module/tasks/endpoints/admin/materialize_assignments.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
|
||||
if (!$isCli && !TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
if (!$isCli) {
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$count = $services['assignmentResolverService']->materializeAssignmentsForAllActiveTasks();
|
||||
|
||||
CronHeartbeat::record('materialize_assignments', ['materialized' => $count]);
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'materialized' => $count,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
63
module/tasks/endpoints/admin/recalculate_escalations.php
Normal file
63
module/tasks/endpoints/admin/recalculate_escalations.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
|
||||
if (!$isCli && !TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
if (!$isCli) {
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$apply = true;
|
||||
$taskId = null;
|
||||
|
||||
if ($isCli) {
|
||||
$options = getopt('', ['task_id::', 'apply', 'dry-run']);
|
||||
if ($options !== false) {
|
||||
if (isset($options['task_id']) && !is_array($options['task_id'])) {
|
||||
$parsedTaskId = (int) $options['task_id'];
|
||||
if ($parsedTaskId > 0) {
|
||||
$taskId = $parsedTaskId;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($options['dry-run'])) {
|
||||
$apply = false;
|
||||
}
|
||||
|
||||
if (isset($options['apply'])) {
|
||||
$apply = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$summary = $services['escalationService']->recalculateEscalationsDetailed($apply, $taskId);
|
||||
$changed = (int) ($summary['changed_assignments'] ?? 0);
|
||||
|
||||
if ($apply) {
|
||||
CronHeartbeat::record('recalculate_escalations', ['changed' => $changed]);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'changed' => $changed,
|
||||
'summary' => $summary,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
67
module/tasks/endpoints/admin/reset_assignment_progress.php
Normal file
67
module/tasks/endpoints/admin/reset_assignment_progress.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
|
||||
}
|
||||
|
||||
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
|
||||
$isJson = strpos($contentType, 'application/json') !== false;
|
||||
|
||||
if ($isJson) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$body = json_decode($rawBody ?: '{}', true);
|
||||
if (!is_array($body)) {
|
||||
$body = [];
|
||||
}
|
||||
|
||||
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
|
||||
if ($csrfToken !== '') {
|
||||
$_POST['csrf_token'] = $csrfToken;
|
||||
}
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = (int) ($body['task_id'] ?? 0);
|
||||
$assignmentId = (int) ($body['assignment_id'] ?? 0);
|
||||
} else {
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
}
|
||||
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
|
||||
}
|
||||
|
||||
if ($assignmentId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige assignment_id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$taskService = $services['taskDefinitionService'] ?? null;
|
||||
if (!$taskService instanceof TaskDefinitionService) {
|
||||
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$stats = $taskService->resetAssignmentProgress($taskId, $assignmentId, $auth->contactId());
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'assignment_id' => $assignmentId,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
60
module/tasks/endpoints/admin/reset_cycle_progress.php
Normal file
60
module/tasks/endpoints/admin/reset_cycle_progress.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Methode nicht erlaubt'], 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Keine Berechtigung'], 403);
|
||||
}
|
||||
|
||||
$contentType = trim((string) ($_SERVER['CONTENT_TYPE'] ?? ''));
|
||||
$isJson = strpos($contentType, 'application/json') !== false;
|
||||
|
||||
if ($isJson) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$body = json_decode($rawBody ?: '{}', true);
|
||||
if (!is_array($body)) {
|
||||
$body = [];
|
||||
}
|
||||
|
||||
$csrfToken = trim((string) ($body['csrf_token'] ?? ''));
|
||||
if ($csrfToken !== '') {
|
||||
$_POST['csrf_token'] = $csrfToken;
|
||||
}
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = (int) ($body['task_id'] ?? 0);
|
||||
} else {
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
}
|
||||
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::json(['success' => false, 'error' => 'Ungültige task_id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$taskService = $services['taskDefinitionService'] ?? null;
|
||||
if (!$taskService instanceof TaskDefinitionService) {
|
||||
throw new RuntimeException('TaskDefinitionService konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$stats = $taskService->resetCurrentCycleProgress($taskId, $auth->contactId());
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
284
module/tasks/endpoints/admin/sync_assignments.php
Normal file
284
module/tasks/endpoints/admin/sync_assignments.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_sync_assignments_append_query_params')) {
|
||||
function task_cert_sync_assignments_append_query_params(string $url, array $params): string
|
||||
{
|
||||
if ($url === '' || $params === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) {
|
||||
$queryString = http_build_query($params);
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = strpos($url, '?') === false ? '?' : '&';
|
||||
return $url . $separator . $queryString;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if ($path !== '' && substr($path, -1) !== '/') {
|
||||
$parts['path'] = $path . '/';
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
$rawQuery = (string) ($parts['query'] ?? '');
|
||||
if ($rawQuery !== '') {
|
||||
parse_str($rawQuery, $existing);
|
||||
}
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
if (!is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($existing[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing[$key] = $value;
|
||||
}
|
||||
|
||||
$parts['query'] = http_build_query($existing);
|
||||
|
||||
return task_cert_sync_assignments_build_url($parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_sync_assignments_build_url')) {
|
||||
function task_cert_sync_assignments_build_url(array $parts): string
|
||||
{
|
||||
$url = '';
|
||||
|
||||
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
|
||||
$url .= $parts['scheme'] . '://';
|
||||
}
|
||||
|
||||
if (isset($parts['user']) && $parts['user'] !== '') {
|
||||
$url .= $parts['user'];
|
||||
if (isset($parts['pass']) && $parts['pass'] !== '') {
|
||||
$url .= ':' . $parts['pass'];
|
||||
}
|
||||
$url .= '@';
|
||||
}
|
||||
|
||||
if (isset($parts['host']) && $parts['host'] !== '') {
|
||||
$url .= $parts['host'];
|
||||
}
|
||||
|
||||
if (isset($parts['port'])) {
|
||||
$url .= ':' . (int) $parts['port'];
|
||||
}
|
||||
|
||||
$url .= (string) ($parts['path'] ?? '');
|
||||
|
||||
$query = (string) ($parts['query'] ?? '');
|
||||
if ($query !== '') {
|
||||
$url .= '?' . $query;
|
||||
}
|
||||
|
||||
$fragment = (string) ($parts['fragment'] ?? '');
|
||||
if ($fragment !== '') {
|
||||
$url .= '#' . $fragment;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_sync_assignments_zero_totals')) {
|
||||
function task_cert_sync_assignments_zero_totals(bool $applied): array
|
||||
{
|
||||
return [
|
||||
'task_count' => 0,
|
||||
'target_count' => 0,
|
||||
'existing_count' => 0,
|
||||
'create_count' => 0,
|
||||
'update_count' => 0,
|
||||
'stale_count' => 0,
|
||||
'protected_completed_count' => 0,
|
||||
'protected_certificate_count' => 0,
|
||||
'remove_count' => 0,
|
||||
'remove_with_submissions_count' => 0,
|
||||
'applied' => $applied,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_sync_assignments_sum_totals')) {
|
||||
function task_cert_sync_assignments_sum_totals(array $results, bool $applied): array
|
||||
{
|
||||
$totals = task_cert_sync_assignments_zero_totals($applied);
|
||||
foreach ($results as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totals['task_count']++;
|
||||
foreach ([
|
||||
'target_count',
|
||||
'existing_count',
|
||||
'create_count',
|
||||
'update_count',
|
||||
'stale_count',
|
||||
'protected_completed_count',
|
||||
'protected_certificate_count',
|
||||
'remove_count',
|
||||
'remove_with_submissions_count',
|
||||
] as $key) {
|
||||
$totals[$key] += (int) ($result[$key] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
|
||||
if (!$isCli && !TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
if (!$isCli) {
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
}
|
||||
|
||||
$taskId = 0;
|
||||
$mode = 'dry_run';
|
||||
$scope = 'current_cycle';
|
||||
$redirectTo = '';
|
||||
$tabIndex = 0;
|
||||
|
||||
if ($isCli) {
|
||||
$options = getopt('', ['task_id::', 'mode::', 'scope::', 'apply', 'dry-run']);
|
||||
if ($options !== false) {
|
||||
if (isset($options['task_id'])) {
|
||||
$taskId = (int) $options['task_id'];
|
||||
}
|
||||
|
||||
if (isset($options['mode']) && !is_array($options['mode'])) {
|
||||
$mode = trim((string) $options['mode']);
|
||||
}
|
||||
|
||||
if (isset($options['scope']) && !is_array($options['scope'])) {
|
||||
$scope = trim((string) $options['scope']);
|
||||
}
|
||||
|
||||
if (isset($options['apply'])) {
|
||||
$mode = 'apply';
|
||||
}
|
||||
|
||||
if (isset($options['dry-run'])) {
|
||||
$mode = 'dry_run';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
$mode = TaskCertRequest::postString('mode', 'dry_run');
|
||||
$scope = TaskCertRequest::postString('scope', 'current_cycle');
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
$tabIndex = TaskCertRequest::postInt('tab', 0);
|
||||
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::error('Ungültige task_id', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if ($tabIndex < 0) {
|
||||
$tabIndex = 0;
|
||||
}
|
||||
|
||||
if (!in_array($mode, ['dry_run', 'apply'], true)) {
|
||||
TaskCertResponse::error('Ungültiger mode', 400);
|
||||
}
|
||||
|
||||
if ($scope !== 'current_cycle') {
|
||||
TaskCertResponse::error('Ungültiger scope', 400);
|
||||
}
|
||||
|
||||
$apply = $mode === 'apply';
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$resolver = $services['assignmentResolverService'] ?? null;
|
||||
if (!$resolver instanceof AssignmentResolverService) {
|
||||
throw new RuntimeException('AssignmentResolverService nicht verfügbar.');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$singleResult = null;
|
||||
|
||||
if ($taskId > 0) {
|
||||
$singleResult = $resolver->syncAssignmentsForTask($taskId, $apply, $scope);
|
||||
$results[] = $singleResult;
|
||||
} else {
|
||||
$results = $resolver->syncAssignmentsForAllActiveTasks($apply, $scope);
|
||||
}
|
||||
|
||||
$totals = task_cert_sync_assignments_sum_totals($results, $apply);
|
||||
|
||||
if ($apply && $taskId <= 0) {
|
||||
CronHeartbeat::record('sync_assignments', [
|
||||
'task_count' => (int) ($totals['task_count'] ?? 0),
|
||||
'create_count' => (int) ($totals['create_count'] ?? 0),
|
||||
'remove_count' => (int) ($totals['remove_count'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$isCli && $redirectTo !== '' && $singleResult !== null) {
|
||||
$redirectTo = task_cert_sync_assignments_append_query_params($redirectTo, [
|
||||
'sync_done' => 1,
|
||||
'sync_mode' => $mode,
|
||||
'sync_applied' => $apply ? 1 : 0,
|
||||
'sync_scope' => $scope,
|
||||
'tab' => $tabIndex,
|
||||
'sync_cycle_key' => (string) ($singleResult['cycle_key'] ?? ''),
|
||||
'sync_target_count' => (int) ($singleResult['target_count'] ?? 0),
|
||||
'sync_existing_count' => (int) ($singleResult['existing_count'] ?? 0),
|
||||
'sync_create_count' => (int) ($singleResult['create_count'] ?? 0),
|
||||
'sync_update_count' => (int) ($singleResult['update_count'] ?? 0),
|
||||
'sync_stale_count' => (int) ($singleResult['stale_count'] ?? 0),
|
||||
'sync_protected_completed_count' => (int) ($singleResult['protected_completed_count'] ?? 0),
|
||||
'sync_protected_certificate_count' => (int) ($singleResult['protected_certificate_count'] ?? 0),
|
||||
'sync_remove_count' => (int) ($singleResult['remove_count'] ?? 0),
|
||||
'sync_remove_with_submissions_count' => (int) ($singleResult['remove_with_submissions_count'] ?? 0),
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'mode' => $mode,
|
||||
'scope' => $scope,
|
||||
'task_id' => $taskId > 0 ? $taskId : null,
|
||||
'result' => $singleResult,
|
||||
'results' => $taskId > 0 ? [] : $results,
|
||||
'totals' => $totals,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
if (!$isCli && $redirectTo !== '') {
|
||||
$redirectTo = task_cert_sync_assignments_append_query_params($redirectTo, [
|
||||
'sync_done' => 0,
|
||||
'tab' => $tabIndex,
|
||||
'sync_error' => $throwable->getMessage(),
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
148
module/tasks/endpoints/admin/task_category_delete.php
Normal file
148
module/tasks/endpoints/admin/task_category_delete.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_category_delete_append_query_params')) {
|
||||
function task_cert_category_delete_append_query_params(string $url, array $params): string
|
||||
{
|
||||
if ($url === '' || $params === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) {
|
||||
$queryString = http_build_query($params);
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = strpos($url, '?') === false ? '?' : '&';
|
||||
return $url . $separator . $queryString;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if ($path !== '' && substr($path, -1) !== '/') {
|
||||
$parts['path'] = $path . '/';
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
$rawQuery = (string) ($parts['query'] ?? '');
|
||||
if ($rawQuery !== '') {
|
||||
parse_str($rawQuery, $existing);
|
||||
}
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
if (!is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($existing[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing[$key] = $value;
|
||||
}
|
||||
|
||||
$parts['query'] = http_build_query($existing);
|
||||
return task_cert_category_delete_build_url($parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_category_delete_build_url')) {
|
||||
function task_cert_category_delete_build_url(array $parts): string
|
||||
{
|
||||
$url = '';
|
||||
|
||||
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
|
||||
$url .= $parts['scheme'] . '://';
|
||||
}
|
||||
|
||||
if (isset($parts['user']) && $parts['user'] !== '') {
|
||||
$url .= $parts['user'];
|
||||
if (isset($parts['pass']) && $parts['pass'] !== '') {
|
||||
$url .= ':' . $parts['pass'];
|
||||
}
|
||||
$url .= '@';
|
||||
}
|
||||
|
||||
if (isset($parts['host']) && $parts['host'] !== '') {
|
||||
$url .= $parts['host'];
|
||||
}
|
||||
|
||||
if (isset($parts['port'])) {
|
||||
$url .= ':' . (int) $parts['port'];
|
||||
}
|
||||
|
||||
$url .= (string) ($parts['path'] ?? '');
|
||||
|
||||
$query = (string) ($parts['query'] ?? '');
|
||||
if ($query !== '') {
|
||||
$url .= '?' . $query;
|
||||
}
|
||||
|
||||
$fragment = (string) ($parts['fragment'] ?? '');
|
||||
if ($fragment !== '') {
|
||||
$url .= '#' . $fragment;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$categoryId = TaskCertRequest::postInt('category_id', 0);
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
TaskCertResponse::error('Ungültige category_id', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$categoryService = $services['taskCategoryService'] ?? null;
|
||||
if (!$categoryService instanceof TaskCategoryService) {
|
||||
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
|
||||
}
|
||||
|
||||
$result = $categoryService->deleteCategory($categoryId, $auth->contactId());
|
||||
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_delete_append_query_params($redirectTo, [
|
||||
'cat_done' => 1,
|
||||
'cat_action' => 'category_delete',
|
||||
'cat_error' => null,
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'action' => 'category_delete',
|
||||
'result' => $result,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_delete_append_query_params($redirectTo, [
|
||||
'cat_done' => 0,
|
||||
'cat_error' => $throwable->getMessage(),
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
154
module/tasks/endpoints/admin/task_category_set_active.php
Normal file
154
module/tasks/endpoints/admin/task_category_set_active.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_category_active_append_query_params')) {
|
||||
function task_cert_category_active_append_query_params(string $url, array $params): string
|
||||
{
|
||||
if ($url === '' || $params === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) {
|
||||
$queryString = http_build_query($params);
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = strpos($url, '?') === false ? '?' : '&';
|
||||
return $url . $separator . $queryString;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if ($path !== '' && substr($path, -1) !== '/') {
|
||||
$parts['path'] = $path . '/';
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
$rawQuery = (string) ($parts['query'] ?? '');
|
||||
if ($rawQuery !== '') {
|
||||
parse_str($rawQuery, $existing);
|
||||
}
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
if (!is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($existing[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing[$key] = $value;
|
||||
}
|
||||
|
||||
$parts['query'] = http_build_query($existing);
|
||||
return task_cert_category_active_build_url($parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_category_active_build_url')) {
|
||||
function task_cert_category_active_build_url(array $parts): string
|
||||
{
|
||||
$url = '';
|
||||
|
||||
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
|
||||
$url .= $parts['scheme'] . '://';
|
||||
}
|
||||
|
||||
if (isset($parts['user']) && $parts['user'] !== '') {
|
||||
$url .= $parts['user'];
|
||||
if (isset($parts['pass']) && $parts['pass'] !== '') {
|
||||
$url .= ':' . $parts['pass'];
|
||||
}
|
||||
$url .= '@';
|
||||
}
|
||||
|
||||
if (isset($parts['host']) && $parts['host'] !== '') {
|
||||
$url .= $parts['host'];
|
||||
}
|
||||
|
||||
if (isset($parts['port'])) {
|
||||
$url .= ':' . (int) $parts['port'];
|
||||
}
|
||||
|
||||
$url .= (string) ($parts['path'] ?? '');
|
||||
|
||||
$query = (string) ($parts['query'] ?? '');
|
||||
if ($query !== '') {
|
||||
$url .= '?' . $query;
|
||||
}
|
||||
|
||||
$fragment = (string) ($parts['fragment'] ?? '');
|
||||
if ($fragment !== '') {
|
||||
$url .= '#' . $fragment;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$categoryId = TaskCertRequest::postInt('category_id', 0);
|
||||
$active = TaskCertRequest::postInt('active', -1);
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
TaskCertResponse::error('Ungültige category_id', 400);
|
||||
}
|
||||
|
||||
if (!in_array($active, [0, 1], true)) {
|
||||
TaskCertResponse::error('Ungültiger active-Wert', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$categoryService = $services['taskCategoryService'] ?? null;
|
||||
if (!$categoryService instanceof TaskCategoryService) {
|
||||
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
|
||||
}
|
||||
|
||||
$result = $categoryService->setActive($categoryId, $active, $auth->contactId());
|
||||
$action = (string) ($result['action'] ?? ($active === 1 ? 'category_activate' : 'category_deactivate'));
|
||||
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_active_append_query_params($redirectTo, [
|
||||
'cat_done' => 1,
|
||||
'cat_action' => $action,
|
||||
'cat_error' => null,
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'action' => $action,
|
||||
'category' => $result['category'] ?? null,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_active_append_query_params($redirectTo, [
|
||||
'cat_done' => 0,
|
||||
'cat_error' => $throwable->getMessage(),
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
155
module/tasks/endpoints/admin/task_category_upsert.php
Normal file
155
module/tasks/endpoints/admin/task_category_upsert.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_category_upsert_append_query_params')) {
|
||||
function task_cert_category_upsert_append_query_params(string $url, array $params): string
|
||||
{
|
||||
if ($url === '' || $params === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) {
|
||||
$queryString = http_build_query($params);
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = strpos($url, '?') === false ? '?' : '&';
|
||||
return $url . $separator . $queryString;
|
||||
}
|
||||
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if ($path !== '' && substr($path, -1) !== '/') {
|
||||
$parts['path'] = $path . '/';
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
$rawQuery = (string) ($parts['query'] ?? '');
|
||||
if ($rawQuery !== '') {
|
||||
parse_str($rawQuery, $existing);
|
||||
}
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
if (!is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($existing[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing[$key] = $value;
|
||||
}
|
||||
|
||||
$parts['query'] = http_build_query($existing);
|
||||
return task_cert_category_upsert_build_url($parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('task_cert_category_upsert_build_url')) {
|
||||
function task_cert_category_upsert_build_url(array $parts): string
|
||||
{
|
||||
$url = '';
|
||||
|
||||
if (isset($parts['scheme']) && $parts['scheme'] !== '') {
|
||||
$url .= $parts['scheme'] . '://';
|
||||
}
|
||||
|
||||
if (isset($parts['user']) && $parts['user'] !== '') {
|
||||
$url .= $parts['user'];
|
||||
if (isset($parts['pass']) && $parts['pass'] !== '') {
|
||||
$url .= ':' . $parts['pass'];
|
||||
}
|
||||
$url .= '@';
|
||||
}
|
||||
|
||||
if (isset($parts['host']) && $parts['host'] !== '') {
|
||||
$url .= $parts['host'];
|
||||
}
|
||||
|
||||
if (isset($parts['port'])) {
|
||||
$url .= ':' . (int) $parts['port'];
|
||||
}
|
||||
|
||||
$url .= (string) ($parts['path'] ?? '');
|
||||
|
||||
$query = (string) ($parts['query'] ?? '');
|
||||
if ($query !== '') {
|
||||
$url .= '?' . $query;
|
||||
}
|
||||
|
||||
$fragment = (string) ($parts['fragment'] ?? '');
|
||||
if ($fragment !== '') {
|
||||
$url .= '#' . $fragment;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$categoryId = TaskCertRequest::postInt('category_id', 0);
|
||||
$name = TaskCertRequest::postString('name', '');
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
$activeRaw = TaskCertRequest::postString('active', '');
|
||||
$activeOverride = null;
|
||||
if ($activeRaw !== '') {
|
||||
$activeValue = (int) $activeRaw;
|
||||
if (!in_array($activeValue, [0, 1], true)) {
|
||||
TaskCertResponse::error('Ungültiger Statuswert', 400);
|
||||
}
|
||||
$activeOverride = $activeValue;
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$categoryService = $services['taskCategoryService'] ?? null;
|
||||
if (!$categoryService instanceof TaskCategoryService) {
|
||||
throw new RuntimeException('Kategorie-Service nicht verfügbar.');
|
||||
}
|
||||
|
||||
$result = $categoryService->upsert($categoryId, $name, $auth->contactId(), $activeOverride);
|
||||
$action = (string) ($result['action'] ?? ($categoryId > 0 ? 'category_update' : 'category_create'));
|
||||
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_upsert_append_query_params($redirectTo, [
|
||||
'cat_done' => 1,
|
||||
'cat_action' => $action,
|
||||
'cat_error' => null,
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'category' => $result['category'] ?? null,
|
||||
'action' => $action,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($redirectTo !== '') {
|
||||
$redirectTo = task_cert_category_upsert_append_query_params($redirectTo, [
|
||||
'cat_done' => 0,
|
||||
'cat_error' => $throwable->getMessage(),
|
||||
]);
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
40
module/tasks/endpoints/admin/task_delete.php
Normal file
40
module/tasks/endpoints/admin/task_delete.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::error('Ungültige task_id', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$services['taskDefinitionService']->softDeleteTask($taskId);
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
if ($redirectTo !== '') {
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
53
module/tasks/endpoints/admin/task_delete_hard.php
Normal file
53
module/tasks/endpoints/admin/task_delete_hard.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
if ($taskId <= 0) {
|
||||
TaskCertResponse::error('Ungültige task_id', 400);
|
||||
}
|
||||
|
||||
$confirmed = TaskCertRequest::postInt('confirm_hard_delete', 0) === 1;
|
||||
if (!$confirmed) {
|
||||
TaskCertResponse::error('Bitte endgültiges Löschen bestätigen.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$impact = $services['taskDefinitionService']->hardDeleteTask($taskId);
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
if ($redirectTo !== '') {
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'deleted' => [
|
||||
'task' => 1,
|
||||
'methods' => (int) ($impact['methods'] ?? 0),
|
||||
'assignments' => (int) ($impact['assignments'] ?? 0),
|
||||
'submissions' => (int) ($impact['submissions'] ?? 0),
|
||||
'certificates' => (int) ($impact['certificates'] ?? 0),
|
||||
'escalation_events' => (int) ($impact['escalation_events'] ?? 0),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
429
module/tasks/endpoints/admin/task_upsert.php
Normal file
429
module/tasks/endpoints/admin/task_upsert.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_append_query_params')) {
|
||||
function task_cert_append_query_params(string $url, array $params): string
|
||||
{
|
||||
if ($url === '' || $params === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$queryString = http_build_query($params);
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = strpos($url, '?') === false ? '?' : '&';
|
||||
return $url . $separator . $queryString;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->hasPermission('r-task-cert-admin')) {
|
||||
TaskCertResponse::error('Keine Berechtigung', 403);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$services = task_cert_services();
|
||||
$taskService = $services['taskDefinitionService'];
|
||||
|
||||
$parseCsvOptions = static function (string $raw): array {
|
||||
$result = [];
|
||||
$parts = preg_split('/[,\n]/', $raw) ?: [];
|
||||
foreach ($parts as $part) {
|
||||
$item = trim((string) $part);
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'value' => $item,
|
||||
'label' => $item,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
};
|
||||
|
||||
$slugifyFieldKey = static function (string $label): string {
|
||||
$map = [
|
||||
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
|
||||
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
|
||||
];
|
||||
$slug = strtr(mb_strtolower($label, 'UTF-8'), $map);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '_', $slug) ?? '';
|
||||
return trim($slug, '_');
|
||||
};
|
||||
|
||||
$buildMethods = static function () use ($parseCsvOptions, $slugifyFieldKey): array {
|
||||
$methodEnabled = TaskCertRequest::postArray('method_enabled');
|
||||
$orderedTypes = TaskCertConstants::METHOD_TYPES;
|
||||
|
||||
$methods = [];
|
||||
$sortOrder = 0;
|
||||
|
||||
foreach ($orderedTypes as $methodType) {
|
||||
if ((int) ($methodEnabled[$methodType] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$method = [
|
||||
'method_type' => $methodType,
|
||||
'is_required' => 1,
|
||||
'sort_order' => $sortOrder,
|
||||
'active' => 1,
|
||||
'settings' => [],
|
||||
];
|
||||
|
||||
if ($methodType === 'knowledge_read') {
|
||||
$knowledgePosts = [];
|
||||
$postIds = TaskCertRequest::postIntArray('knowledge_post_ids');
|
||||
foreach ($postIds as $index => $postId) {
|
||||
$knowledgePosts[] = [
|
||||
'knowledge_post_id' => $postId,
|
||||
'required' => 1,
|
||||
'sort_order' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
$method['knowledge_posts'] = $knowledgePosts;
|
||||
}
|
||||
|
||||
if ($methodType === 'form_submit') {
|
||||
$formFields = [];
|
||||
$rows = TaskCertRequest::postArray('form_fields');
|
||||
|
||||
foreach ($rows as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string) ($row['label'] ?? ''));
|
||||
if ($label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldKey = trim((string) ($row['field_key'] ?? ''));
|
||||
if ($fieldKey === '') {
|
||||
$base = $slugifyFieldKey($label);
|
||||
if ($base === '') {
|
||||
$base = 'field';
|
||||
}
|
||||
$fieldKey = $base;
|
||||
$usedKeys = array_column($formFields, 'field_key');
|
||||
$suffix = 2;
|
||||
while (in_array($fieldKey, $usedKeys, true)) {
|
||||
$fieldKey = $base . '_' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
$fieldType = (string) ($row['field_type'] ?? 'text');
|
||||
if (!TaskCertConstants::isValidFieldType($fieldType)) {
|
||||
$fieldType = 'text';
|
||||
}
|
||||
|
||||
$formFields[] = [
|
||||
'field_key' => $fieldKey,
|
||||
'label' => $label,
|
||||
'field_type' => $fieldType,
|
||||
'is_required' => (int) (($row['is_required'] ?? 0) ? 1 : 0),
|
||||
'options' => $parseCsvOptions((string) ($row['options_text'] ?? '')),
|
||||
'placeholder' => trim((string) ($row['placeholder'] ?? '')),
|
||||
'prefill_column' => ContactPrefillColumns::sanitize(trim((string) ($row['prefill_column'] ?? ''))),
|
||||
'sort_order' => count($formFields),
|
||||
'active' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
$method['form_fields'] = $formFields;
|
||||
}
|
||||
|
||||
if ($methodType === 'quiz') {
|
||||
$quizQuestions = [];
|
||||
$rows = TaskCertRequest::postArray('quiz_questions');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$questionText = trim((string) ($row['question_text'] ?? ''));
|
||||
if ($questionText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$correctOption = strtolower(trim((string) ($row['correct_option'] ?? 'a')));
|
||||
if (!in_array($correctOption, ['a', 'b', 'c', 'd'], true)) {
|
||||
$correctOption = 'a';
|
||||
}
|
||||
|
||||
$optionsMap = [
|
||||
'a' => trim((string) ($row['option_a'] ?? '')),
|
||||
'b' => trim((string) ($row['option_b'] ?? '')),
|
||||
'c' => trim((string) ($row['option_c'] ?? '')),
|
||||
'd' => trim((string) ($row['option_d'] ?? '')),
|
||||
];
|
||||
|
||||
$options = [];
|
||||
foreach (['a', 'b', 'c', 'd'] as $position => $letter) {
|
||||
$optionText = $optionsMap[$letter];
|
||||
if ($optionText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'option_text' => $optionText,
|
||||
'is_correct' => $letter === $correctOption ? 1 : 0,
|
||||
'sort_order' => $position,
|
||||
];
|
||||
}
|
||||
|
||||
if ($options === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quizQuestions[] = [
|
||||
'question_text' => $questionText,
|
||||
'sort_order' => count($quizQuestions),
|
||||
'active' => 1,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
$method['quiz_questions'] = $quizQuestions;
|
||||
}
|
||||
|
||||
$methods[] = $method;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
return $methods;
|
||||
};
|
||||
|
||||
$buildTargetRules = static function (): array {
|
||||
$mandantIds = array_values(array_unique(TaskCertRequest::postIntArray('target_mandant_ids')));
|
||||
$departmentIds = array_values(array_unique(TaskCertRequest::postIntArray('target_department_ids')));
|
||||
$roleIds = array_values(array_unique(TaskCertRequest::postIntArray('target_role_ids')));
|
||||
$einrichtIds = array_values(array_unique(TaskCertRequest::postIntArray('target_einricht_ids')));
|
||||
$fachbereichIds = array_values(array_unique(TaskCertRequest::postIntArray('target_fachbereich_ids')));
|
||||
|
||||
$mandantValues = $mandantIds === [] ? [0] : $mandantIds;
|
||||
$departmentValues = $departmentIds === [] ? [0] : $departmentIds;
|
||||
$roleValues = $roleIds === [] ? [0] : $roleIds;
|
||||
$einrichtValues = $einrichtIds === [] ? [0] : $einrichtIds;
|
||||
$fachbereichValues = $fachbereichIds === [] ? [0] : $fachbereichIds;
|
||||
|
||||
$rules = [];
|
||||
$count = 0;
|
||||
$maxRules = 5000;
|
||||
|
||||
foreach ($mandantValues as $mandantId) {
|
||||
foreach ($departmentValues as $departmentId) {
|
||||
foreach ($roleValues as $roleId) {
|
||||
foreach ($einrichtValues as $einrichtId) {
|
||||
foreach ($fachbereichValues as $fachbereichId) {
|
||||
if ((int) $mandantId === 0 && (int) $departmentId === 0 && (int) $roleId === 0
|
||||
&& (int) $einrichtId === 0 && (int) $fachbereichId === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[] = [
|
||||
'main_mandant_id' => (int) $mandantId ?: null,
|
||||
'main_department_id' => (int) $departmentId ?: null,
|
||||
'main_role_id' => (int) $roleId ?: null,
|
||||
'main_einricht_id' => (int) $einrichtId ?: null,
|
||||
'main_bereich_id' => (int) $fachbereichId ?: null,
|
||||
'active' => 1,
|
||||
];
|
||||
|
||||
$count++;
|
||||
if ($count > $maxRules) {
|
||||
throw new RuntimeException('Zu viele Regelkombinationen. Bitte Auswahl einschränken.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
};
|
||||
|
||||
$buildTargetOverrides = static function (): array {
|
||||
$includeReason = TaskCertRequest::postString('override_include_reason', '');
|
||||
$excludeReason = TaskCertRequest::postString('override_exclude_reason', '');
|
||||
$includeIds = array_values(array_unique(TaskCertRequest::postIntArray('override_include_contact_ids')));
|
||||
$excludeIds = array_values(array_unique(TaskCertRequest::postIntArray('override_exclude_contact_ids')));
|
||||
|
||||
// exclude wins when the same contact is selected in both lists
|
||||
$includeIds = array_values(array_diff($includeIds, $excludeIds));
|
||||
|
||||
$overrides = [];
|
||||
|
||||
foreach ($includeIds as $contactId) {
|
||||
$overrides[] = [
|
||||
'main_contact_id' => $contactId,
|
||||
'override_type' => 'include',
|
||||
'reason' => $includeReason,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($excludeIds as $contactId) {
|
||||
$overrides[] = [
|
||||
'main_contact_id' => $contactId,
|
||||
'override_type' => 'exclude',
|
||||
'reason' => $excludeReason,
|
||||
];
|
||||
}
|
||||
|
||||
return $overrides;
|
||||
};
|
||||
|
||||
try {
|
||||
$methods = $buildMethods();
|
||||
if ($methods === []) {
|
||||
throw new RuntimeException('Mindestens eine Erledigungsart muss aktiviert sein.');
|
||||
}
|
||||
|
||||
$deadlineRuleType = TaskCertRequest::postString('deadline_rule_type', 'cycle_end');
|
||||
if (!in_array($deadlineRuleType, ['cycle_end', 'offset_days', 'weekday', 'monthday'], true)) {
|
||||
$deadlineRuleType = 'cycle_end';
|
||||
}
|
||||
|
||||
$deadlineOffsetDays = null;
|
||||
$deadlineWeekday = null;
|
||||
$deadlineMonthday = null;
|
||||
|
||||
if ($deadlineRuleType === 'offset_days') {
|
||||
$deadlineOffsetDays = TaskCertRequest::postInt('deadline_offset_days', 0);
|
||||
if ($deadlineOffsetDays <= 0) {
|
||||
throw new RuntimeException('Bitte Frist in Tagen größer 0 angeben.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($deadlineRuleType === 'weekday') {
|
||||
$deadlineWeekday = TaskCertRequest::postInt('deadline_weekday', 0);
|
||||
if ($deadlineWeekday < 1 || $deadlineWeekday > 7) {
|
||||
throw new RuntimeException('Bitte einen gültigen Wochentag (1-7) auswählen.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($deadlineRuleType === 'monthday') {
|
||||
$deadlineMonthday = TaskCertRequest::postInt('deadline_monthday', 0);
|
||||
if ($deadlineMonthday < 1 || $deadlineMonthday > 31) {
|
||||
throw new RuntimeException('Bitte einen gültigen Monatstag (1-31) auswählen.');
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'actor_id' => $auth->contactId(),
|
||||
'title' => TaskCertRequest::postString('title'),
|
||||
'description' => TaskCertHtml::sanitize(TaskCertRequest::postString('description')),
|
||||
'task_category_id' => TaskCertRequest::postInt('task_category_id', 0),
|
||||
'frequency_type' => TaskCertRequest::postString('frequency_type', 'once'),
|
||||
'recurrence_basis' => TaskCertRequest::postString('recurrence_basis', 'calendar'),
|
||||
'recurrence_unit' => TaskCertRequest::postString('recurrence_unit', ''),
|
||||
'recurrence_interval' => TaskCertRequest::postInt('recurrence_interval', 1),
|
||||
'start_date' => TaskCertRequest::postString('start_date', date('Y-m-d')),
|
||||
'deadline_rule_type' => $deadlineRuleType,
|
||||
'deadline_offset_days' => $deadlineOffsetDays,
|
||||
'deadline_weekday' => $deadlineWeekday,
|
||||
'deadline_monthday' => $deadlineMonthday,
|
||||
'fulfillment_mode' => TaskCertRequest::postString('fulfillment_mode', 'all'),
|
||||
'certificate_enabled' => TaskCertRequest::postInt('certificate_enabled', 1),
|
||||
'certificate_title' => TaskCertRequest::postString('certificate_title', ''),
|
||||
'certificate_validity_mode' => TaskCertRequest::postString('certificate_validity_mode', ''),
|
||||
'certificate_validity_days' => TaskCertRequest::postString('certificate_validity_days', ''),
|
||||
'show_cycle_period' => TaskCertRequest::postInt('show_cycle_period', 0) === 1 ? 1 : 0,
|
||||
'active' => TaskCertRequest::postInt('active', 1),
|
||||
'methods' => $methods,
|
||||
'target_rules' => $buildTargetRules(),
|
||||
'target_overrides' => $buildTargetOverrides(),
|
||||
'responsible_contact_ids' => array_values(array_unique(TaskCertRequest::postIntArray('responsible_contact_ids'))),
|
||||
'reminder_policy' => [
|
||||
'base_mode' => 'due_date',
|
||||
'first_escalation_days' => TaskCertRequest::postInt('reminder_first_escalation_days', 0),
|
||||
'step_days' => TaskCertRequest::postInt('reminder_step_days', 7),
|
||||
'max_level' => TaskCertRequest::postInt('reminder_max_level', 3),
|
||||
'grace_days' => TaskCertRequest::postInt('reminder_grace_days', 7),
|
||||
'active' => TaskCertRequest::postInt('reminder_active', 0) === 1 ? 1 : 0,
|
||||
],
|
||||
];
|
||||
|
||||
$taskId = TaskCertRequest::postInt('task_id', 0);
|
||||
$resetExistingRecords = $taskId > 0 && TaskCertRequest::postInt('reset_existing_records', 0) === 1;
|
||||
$resetStats = [
|
||||
'cycle_key' => '',
|
||||
'assignment_count' => 0,
|
||||
'submission_count' => 0,
|
||||
'certificate_count' => 0,
|
||||
];
|
||||
|
||||
if ($taskId > 0) {
|
||||
$taskService->updateTask($taskId, $payload);
|
||||
if ($resetExistingRecords) {
|
||||
$resetStats = $taskService->resetCurrentCycleProgress($taskId, $auth->contactId());
|
||||
}
|
||||
} else {
|
||||
$taskId = $taskService->createTask($payload);
|
||||
}
|
||||
|
||||
$afterSave = TaskCertRequest::postString('after_save', 'stay');
|
||||
if (!in_array($afterSave, ['stay', 'close'], true)) {
|
||||
$afterSave = 'stay';
|
||||
}
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
$redirectToStay = TaskCertRequest::postString('redirect_to_stay', '');
|
||||
$redirectToClose = TaskCertRequest::postString('redirect_to_close', '');
|
||||
|
||||
if ($redirectToStay === '') {
|
||||
$redirectToStay = task_cert_view_url('admin.task_form', [
|
||||
'id' => $taskId,
|
||||
'saved' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($redirectToClose === '') {
|
||||
$redirectToClose = task_cert_view_url('admin.task_list');
|
||||
}
|
||||
|
||||
$resolvedRedirect = $redirectTo;
|
||||
if ($resolvedRedirect === '') {
|
||||
$resolvedRedirect = $afterSave === 'close' ? $redirectToClose : $redirectToStay;
|
||||
}
|
||||
|
||||
if ($resolvedRedirect !== '') {
|
||||
$resolvedRedirect = str_replace('{task_id}', (string) $taskId, $resolvedRedirect);
|
||||
if ($resetExistingRecords) {
|
||||
$resolvedRedirect = task_cert_append_query_params($resolvedRedirect, [
|
||||
'reset' => 1,
|
||||
'reset_assignments' => (int) ($resetStats['assignment_count'] ?? 0),
|
||||
'reset_submissions' => (int) ($resetStats['submission_count'] ?? 0),
|
||||
'reset_certificates' => (int) ($resetStats['certificate_count'] ?? 0),
|
||||
]);
|
||||
}
|
||||
TaskCertResponse::redirect($resolvedRedirect);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'task_id' => $taskId,
|
||||
'redirect' => $resolvedRedirect,
|
||||
'reset' => $resetExistingRecords,
|
||||
'reset_stats' => $resetStats,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
43
module/tasks/endpoints/user/request_manual_approval.php
Normal file
43
module/tasks/endpoints/user/request_manual_approval.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
TaskCertResponse::error('Nicht eingeloggt', 401);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
$methodId = TaskCertRequest::postInt('method_id', 0);
|
||||
$note = TaskCertRequest::postString('note', '');
|
||||
|
||||
if ($assignmentId <= 0 || $methodId <= 0) {
|
||||
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$submissionId = $services['completionService']->requestManualApproval($assignmentId, $methodId, $note);
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
if ($redirectTo !== '') {
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'submission_id' => $submissionId,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
48
module/tasks/endpoints/user/submit_form.php
Normal file
48
module/tasks/endpoints/user/submit_form.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
TaskCertResponse::error('Nicht eingeloggt', 401);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
$methodId = TaskCertRequest::postInt('method_id', 0);
|
||||
$values = TaskCertRequest::postArray('values');
|
||||
|
||||
$files = [];
|
||||
if (isset($_FILES['files']) && is_array($_FILES['files'])) {
|
||||
$files = TaskUploadStorage::normalizeFilesInput($_FILES['files']);
|
||||
}
|
||||
|
||||
if ($assignmentId <= 0 || $methodId <= 0) {
|
||||
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$submissionId = $services['completionService']->submitForm($assignmentId, $methodId, $values, $files);
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
if ($redirectTo !== '') {
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'submission_id' => $submissionId,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
57
module/tasks/endpoints/user/submit_quiz.php
Normal file
57
module/tasks/endpoints/user/submit_quiz.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
TaskCertResponse::error('Nicht eingeloggt', 401);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
$methodId = TaskCertRequest::postInt('method_id', 0);
|
||||
$answersRaw = TaskCertRequest::postArray('answers');
|
||||
|
||||
if ($assignmentId <= 0 || $methodId <= 0) {
|
||||
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
foreach ($answersRaw as $questionId => $questionAnswers) {
|
||||
$questionIdInt = (int) $questionId;
|
||||
if ($questionIdInt <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($questionAnswers)) {
|
||||
$questionAnswers = [$questionAnswers];
|
||||
}
|
||||
|
||||
$answers[$questionIdInt] = array_values(array_unique(array_map('intval', $questionAnswers)));
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$submissionId = $services['completionService']->submitQuiz($assignmentId, $methodId, $answers);
|
||||
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
if ($redirectTo !== '') {
|
||||
TaskCertResponse::redirect($redirectTo);
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'submission_id' => $submissionId,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
92
module/tasks/endpoints/user/submit_read.php
Normal file
92
module/tasks/endpoints/user/submit_read.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
|
||||
|
||||
if (!function_exists('task_cert_submit_read_redirect_with_message')) {
|
||||
function task_cert_submit_read_redirect_with_message(string $redirectTo, string $queryKey, string $message): void
|
||||
{
|
||||
if ($redirectTo === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$separator = (strpos($redirectTo, '?') === false) ? '?' : '&';
|
||||
TaskCertResponse::redirect($redirectTo . $separator . $queryKey . '=' . rawurlencode($message));
|
||||
}
|
||||
}
|
||||
|
||||
if (!TaskCertRequest::isPost()) {
|
||||
TaskCertResponse::error('Methode nicht erlaubt', 405);
|
||||
}
|
||||
|
||||
$auth = TaskCertAuthContext::fromGlobals();
|
||||
if (!$auth->isLoggedIn()) {
|
||||
TaskCertResponse::error('Nicht eingeloggt', 401);
|
||||
}
|
||||
|
||||
TaskCertCsrf::assertValidFromRequest(true);
|
||||
|
||||
$assignmentId = TaskCertRequest::postInt('assignment_id', 0);
|
||||
$methodId = TaskCertRequest::postInt('method_id', 0);
|
||||
$postIds = TaskCertRequest::postIntArray('post_ids');
|
||||
$postReadState = TaskCertRequest::postArray('post_read_state');
|
||||
$redirectTo = TaskCertRequest::postString('redirect_to', '');
|
||||
|
||||
if ($assignmentId <= 0 || $methodId <= 0) {
|
||||
TaskCertResponse::error('Ungültige assignment_id oder method_id', 400);
|
||||
}
|
||||
|
||||
if ($postReadState !== []) {
|
||||
foreach ($postReadState as $postIdRaw => $stateRaw) {
|
||||
$postId = (int) $postIdRaw;
|
||||
if ($postId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$state = strtolower(trim((string) $stateRaw));
|
||||
if (in_array($state, ['read', '1', 'yes', 'true'], true)) {
|
||||
$postIds[] = $postId;
|
||||
}
|
||||
}
|
||||
|
||||
$postIds = array_values(array_unique(array_map('intval', $postIds)));
|
||||
$postIds = array_values(array_filter($postIds, static function (int $postId): bool {
|
||||
return $postId > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
$services = task_cert_services();
|
||||
$submissionId = $services['completionService']->submitRead($assignmentId, $methodId, $postIds);
|
||||
$submission = $services['submissionRepo']->findById($submissionId);
|
||||
$submissionStatus = (string) ($submission['status'] ?? '');
|
||||
$payload = is_array($submission['payload'] ?? null) ? $submission['payload'] : [];
|
||||
|
||||
if ($redirectTo !== '') {
|
||||
if ($submissionStatus === 'submitted') {
|
||||
$missingCount = is_array($payload['missing_post_ids'] ?? null) ? count($payload['missing_post_ids']) : 0;
|
||||
$missingNoun = $missingCount === 1 ? 'Pflichtbeitrag' : 'Pflichtbeiträge';
|
||||
$message = $missingCount > 0
|
||||
? ('Zwischengespeichert. Bitte noch ' . $missingCount . ' ' . $missingNoun . ' als gelesen markieren.')
|
||||
: 'Zwischengespeichert. Bitte alle Pflichtbeiträge als gelesen markieren.';
|
||||
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_warning', $message);
|
||||
}
|
||||
|
||||
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_notice', 'Wissensbeiträge erfolgreich bestätigt.');
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => true,
|
||||
'submission_id' => $submissionId,
|
||||
'status' => $submissionStatus !== '' ? $submissionStatus : 'auto_passed',
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
if ($redirectTo !== '') {
|
||||
task_cert_submit_read_redirect_with_message($redirectTo, 'task_cert_error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
TaskCertResponse::json([
|
||||
'success' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
252
module/tasks/lib/AuthContext.php
Normal file
252
module/tasks/lib/AuthContext.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class TaskCertAuthContext
|
||||
{
|
||||
private int $contactId;
|
||||
private int $masterMandantId;
|
||||
private int $currentMandantId;
|
||||
|
||||
public function __construct(int $contactId, int $masterMandantId, int $currentMandantId)
|
||||
{
|
||||
$this->contactId = $contactId;
|
||||
$this->masterMandantId = $masterMandantId;
|
||||
$this->currentMandantId = $currentMandantId;
|
||||
}
|
||||
|
||||
public static function fromGlobals(): self
|
||||
{
|
||||
$mainContact = self::asArray($GLOBALS['main_contact'] ?? ($_SESSION['main_contact'] ?? []));
|
||||
|
||||
$contactId = self::firstPositiveInt([
|
||||
$mainContact['id'] ?? null,
|
||||
$mainContact['main_contact_id'] ?? null,
|
||||
$GLOBALS['main_contact_id'] ?? null,
|
||||
$_SESSION['main_contact_id'] ?? null,
|
||||
$GLOBALS['contact_id'] ?? null,
|
||||
$_SESSION['contact_id'] ?? null,
|
||||
$_SESSION['id'] ?? null,
|
||||
]);
|
||||
|
||||
$masterMandantId = self::firstPositiveInt([
|
||||
$mainContact['master_mandant_id'] ?? null,
|
||||
$mainContact['main_mandant_id'] ?? null,
|
||||
$mainContact['mandant_id'] ?? null,
|
||||
$GLOBALS['master_mandant_id'] ?? null,
|
||||
$_SESSION['master_mandant_id'] ?? null,
|
||||
$GLOBALS['main_mandant_id'] ?? null,
|
||||
$_SESSION['main_mandant_id'] ?? null,
|
||||
self::extractMandantId($GLOBALS['mandant'] ?? null),
|
||||
self::extractMandantId($_SESSION['mandant'] ?? null),
|
||||
]);
|
||||
|
||||
$currentMandantId = self::firstPositiveInt([
|
||||
$mainContact['current_mandant_id'] ?? null,
|
||||
$mainContact['main_mandant_id'] ?? null,
|
||||
$GLOBALS['current_mandant_id'] ?? null,
|
||||
$_SESSION['current_mandant_id'] ?? null,
|
||||
self::extractMandantId($GLOBALS['current_mandant'] ?? null),
|
||||
self::extractMandantId($_SESSION['current_mandant'] ?? null),
|
||||
self::extractMandantId($GLOBALS['mandant'] ?? null),
|
||||
self::extractMandantId($_SESSION['mandant'] ?? null),
|
||||
]);
|
||||
|
||||
if ($currentMandantId <= 0) {
|
||||
$currentMandantId = $masterMandantId;
|
||||
}
|
||||
|
||||
if ($masterMandantId <= 0) {
|
||||
$masterMandantId = $currentMandantId;
|
||||
}
|
||||
|
||||
return new self($contactId, $masterMandantId, $currentMandantId);
|
||||
}
|
||||
|
||||
public function contactId(): int
|
||||
{
|
||||
return $this->contactId;
|
||||
}
|
||||
|
||||
public function masterMandantId(): int
|
||||
{
|
||||
return $this->masterMandantId;
|
||||
}
|
||||
|
||||
public function currentMandantId(): int
|
||||
{
|
||||
return $this->currentMandantId;
|
||||
}
|
||||
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return $this->contactId > 0;
|
||||
}
|
||||
|
||||
public function hasPermission(string $permissionKey): bool
|
||||
{
|
||||
if (!$this->isLoggedIn()) {
|
||||
TaskCertLogger::warning('Permission denied: no contact id in auth context', [
|
||||
'permission' => $permissionKey,
|
||||
'contact_id' => $this->contactId,
|
||||
'master_mandant_id' => $this->masterMandantId,
|
||||
'current_mandant_id' => $this->currentMandantId,
|
||||
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$mandantIds = [];
|
||||
if ($this->currentMandantId > 0) {
|
||||
$mandantIds[] = $this->currentMandantId;
|
||||
}
|
||||
|
||||
if ($this->masterMandantId > 0 && !in_array($this->masterMandantId, $mandantIds, true)) {
|
||||
$mandantIds[] = $this->masterMandantId;
|
||||
}
|
||||
|
||||
if ($mandantIds === []) {
|
||||
$mandantIds[] = 0;
|
||||
}
|
||||
|
||||
$usedFallback = !function_exists('get_permission_state');
|
||||
$checkedStates = [];
|
||||
|
||||
foreach ($mandantIds as $mandantId) {
|
||||
try {
|
||||
$state = $usedFallback
|
||||
? $this->getPermissionStateFallback($permissionKey, $mandantId)
|
||||
: (int) get_permission_state($permissionKey, $mandantId, $this->contactId);
|
||||
$checkedStates[] = [
|
||||
'mandant_id' => $mandantId,
|
||||
'state' => $state,
|
||||
];
|
||||
|
||||
if ($state === 1) {
|
||||
if ($usedFallback) {
|
||||
TaskCertLogger::info('Permission granted via SQL fallback', [
|
||||
'permission' => $permissionKey,
|
||||
'contact_id' => $this->contactId,
|
||||
'mandant_id' => $mandantId,
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
TaskCertLogger::error('Permission check failed', [
|
||||
'permission' => $permissionKey,
|
||||
'contact_id' => $this->contactId,
|
||||
'mandant_id' => $mandantId,
|
||||
'fallback_used' => $usedFallback ? 1 : 0,
|
||||
'exception' => $throwable,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
TaskCertLogger::warning('Permission denied after check', [
|
||||
'permission' => $permissionKey,
|
||||
'contact_id' => $this->contactId,
|
||||
'master_mandant_id' => $this->masterMandantId,
|
||||
'current_mandant_id' => $this->currentMandantId,
|
||||
'checked_mandant_ids' => $mandantIds,
|
||||
'checked_states' => $checkedStates,
|
||||
'fallback_used' => $usedFallback ? 1 : 0,
|
||||
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function asArray($value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function firstPositiveInt(array $values): int
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
$intValue = (int) $value;
|
||||
if ($intValue > 0) {
|
||||
return $intValue;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function extractMandantId($value): int
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return self::firstPositiveInt([
|
||||
$value['id'] ?? null,
|
||||
$value['main_mandant_id'] ?? null,
|
||||
$value['mandant_id'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getPermissionStateFallback(string $permissionKey, int $mandantId): int
|
||||
{
|
||||
$conn = $GLOBALS['mysql_con'] ?? null;
|
||||
if (!($conn instanceof mysqli)) {
|
||||
throw new RuntimeException('Kein mysqli-Handle für Permission-Fallback verfügbar.');
|
||||
}
|
||||
|
||||
$defaultState = 0;
|
||||
$permissionId = 0;
|
||||
|
||||
$stmtPermission = $conn->prepare(
|
||||
'SELECT id, default_active FROM main_permission WHERE code = ? LIMIT 1'
|
||||
);
|
||||
if (!($stmtPermission instanceof mysqli_stmt)) {
|
||||
throw new RuntimeException('Statement für main_permission konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$stmtPermission->bind_param('s', $permissionKey);
|
||||
$stmtPermission->execute();
|
||||
$resultPermission = $stmtPermission->get_result();
|
||||
if ($resultPermission instanceof mysqli_result) {
|
||||
$permission = $resultPermission->fetch_assoc();
|
||||
if (is_array($permission)) {
|
||||
$permissionId = (int) ($permission['id'] ?? 0);
|
||||
$defaultState = (int) ($permission['default_active'] ?? 0) === 1 ? 1 : 0;
|
||||
}
|
||||
$resultPermission->free();
|
||||
}
|
||||
$stmtPermission->close();
|
||||
|
||||
if ($permissionId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$state = $defaultState;
|
||||
$stmtContact = $conn->prepare(
|
||||
'SELECT active FROM main_contact_permission
|
||||
WHERE main_contact_id = ? AND main_mandant_id = ? AND main_permission_id = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if (!($stmtContact instanceof mysqli_stmt)) {
|
||||
throw new RuntimeException('Statement für main_contact_permission konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$contactId = $this->contactId;
|
||||
$stmtContact->bind_param('iii', $contactId, $mandantId, $permissionId);
|
||||
$stmtContact->execute();
|
||||
$resultContact = $stmtContact->get_result();
|
||||
if ($resultContact instanceof mysqli_result) {
|
||||
$contactPermission = $resultContact->fetch_assoc();
|
||||
if (is_array($contactPermission)) {
|
||||
$state = (int) ($contactPermission['active'] ?? 0) === 1 ? 1 : 0;
|
||||
}
|
||||
$resultContact->free();
|
||||
}
|
||||
$stmtContact->close();
|
||||
|
||||
return $state;
|
||||
}
|
||||
}
|
||||
38
module/tasks/lib/ContactPrefillColumns.php
Normal file
38
module/tasks/lib/ContactPrefillColumns.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class ContactPrefillColumns
|
||||
{
|
||||
public const MAP = [
|
||||
'' => '— Keine Vorbefüllung —',
|
||||
'name' => 'Name',
|
||||
'email' => 'E-Mail',
|
||||
'vehicle_registration_place' => 'KFZ Nummernschild',
|
||||
];
|
||||
|
||||
public static function isValid(string $column): bool
|
||||
{
|
||||
return $column === '' || array_key_exists($column, self::MAP);
|
||||
}
|
||||
|
||||
public static function sanitize(string $column): string
|
||||
{
|
||||
return self::isValid($column) ? $column : '';
|
||||
}
|
||||
|
||||
public static function columns(): array
|
||||
{
|
||||
return array_keys(array_filter(self::MAP, static function (string $label, string $key): bool {
|
||||
return $key !== '';
|
||||
}, ARRAY_FILTER_USE_BOTH));
|
||||
}
|
||||
|
||||
public static function resolve(array $contactRow, string $column): string
|
||||
{
|
||||
if ($column === '' || !self::isValid($column)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) ($contactRow[$column] ?? ''));
|
||||
}
|
||||
}
|
||||
72
module/tasks/lib/CronHeartbeat.php
Normal file
72
module/tasks/lib/CronHeartbeat.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
class CronHeartbeat
|
||||
{
|
||||
private static function heartbeatDir(): string
|
||||
{
|
||||
return dirname(__DIR__) . '/logs/heartbeat';
|
||||
}
|
||||
|
||||
public static function record(string $jobName, array $summary = []): void
|
||||
{
|
||||
$dir = self::heartbeatDir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'job' => $jobName,
|
||||
'timestamp' => date('c'),
|
||||
'unix' => time(),
|
||||
'summary' => $summary,
|
||||
];
|
||||
|
||||
$file = $dir . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobName) . '.json';
|
||||
@file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
|
||||
}
|
||||
|
||||
public static function read(string $jobName): ?array
|
||||
{
|
||||
$file = self::heartbeatDir() . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $jobName) . '.json';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function readAll(): array
|
||||
{
|
||||
$dir = self::heartbeatDir();
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$files = @glob($dir . '/*.json');
|
||||
if (!is_array($files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (is_array($data) && isset($data['job'])) {
|
||||
$results[(string) $data['job']] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user