Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt: Knowledgecenter: - Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten - Files-Gallery-Lizenzschlüssel entfernt (config.php) - Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur, nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss), Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge - 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …) Tasks: - Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments) - Task-Definitionen und Verlinkungen geleert - Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1987
module/knowledgecenter_update/Ajax/Ajax.php
Normal file
1987
module/knowledgecenter_update/Ajax/Ajax.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
<?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'];
|
||||||
|
|
||||||
|
if (get_permission_state('r-wiki', $masterMandantId, $contactId) != 1) {
|
||||||
|
respond_error('Keine Berechtigung (r-wiki).', 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
1396
module/knowledgecenter_update/CSS/CSS.php
Normal file
1396
module/knowledgecenter_update/CSS/CSS.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,885 @@
|
|||||||
|
<?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_link 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_link ON main_contact.id = main_contact_link.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_link
|
||||||
|
WHERE main_contact_id = $pid
|
||||||
|
AND main_mandant_id = $sender_mandant_id
|
||||||
|
AND active_d = 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)
|
||||||
|
{
|
||||||
|
// Hier nehmen wir an, dass $GLOBALS["main_contact"] korrekt gesetzt ist
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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":
|
||||||
|
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":
|
||||||
|
require_permission('r-wiki');
|
||||||
|
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":
|
||||||
|
require_permission('r-wiki');
|
||||||
|
$_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);
|
||||||
50
module/knowledgecenter_update/Middleware/Middleware.php
Normal file
50
module/knowledgecenter_update/Middleware/Middleware.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zunächst: Holen der User-Daten (Mandanten, Abteilungen, Rollen)
|
||||||
|
*/
|
||||||
|
$currentUserId = $GLOBALS["main_contact"]["id"] ?? 0;
|
||||||
|
$userMandants = [];
|
||||||
|
$userDepartments = [];
|
||||||
|
$userRoles = [];
|
||||||
|
|
||||||
|
if ($currentUserId) {
|
||||||
|
$query = "SELECT main_mandant_id, main_department_id, main_role_id
|
||||||
|
FROM main_contact_link
|
||||||
|
WHERE main_contact_id = $currentUserId AND active_d = 1";
|
||||||
|
$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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
// Für den globalen Zugriff kannst Du auch die Variablen in $GLOBALS speichern:
|
||||||
|
$GLOBALS['allowedMandants'] = $allowedMandants;
|
||||||
|
$GLOBALS['allowedDepartments'] = $allowedDepartments;
|
||||||
|
$GLOBALS['allowedRoles'] = $allowedRoles;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
3542
module/knowledgecenter_update/Plugins/filesgallery.php
Normal file
3542
module/knowledgecenter_update/Plugins/filesgallery.php
Normal file
File diff suppressed because it is too large
Load Diff
116
module/knowledgecenter_update/Script/Script.php
Normal file
116
module/knowledgecenter_update/Script/Script.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script>
|
||||||
|
function buildFileGalleryUrl(target, mode, ajaxFunction, nocache) {
|
||||||
|
var base = "/module/knowledgecenter_update/Plugins/filesgallery.php?";
|
||||||
|
var startPath = (typeof window.kcPostGalleryPath === "string")
|
||||||
|
? window.kcPostGalleryPath.replace(/^\/+|\/+$/g, "")
|
||||||
|
: "";
|
||||||
|
var params = "input_id=" + encodeURIComponent(target) +
|
||||||
|
"&mode=" + encodeURIComponent(mode) +
|
||||||
|
"&saveajax=" + encodeURIComponent(ajaxFunction || "") +
|
||||||
|
"&kc_lock_path=" + encodeURIComponent(startPath) +
|
||||||
|
"&nocache=" + nocache;
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<iframe class="fullscreen" src="/intranet/de/wiki/?action=Categories&embedded=1" frameborder="0"></iframe>
|
||||||
757
module/knowledgecenter_update/Views/categories_cardform.php
Normal file
757
module/knowledgecenter_update/Views/categories_cardform.php
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
<?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']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
?>
|
||||||
|
<div class="cardform_containern">
|
||||||
|
|
||||||
|
<div class="cardform">
|
||||||
|
<!-- Überschrift und Message-Container -->
|
||||||
|
|
||||||
|
<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): ?>
|
||||||
|
<!-- Container für Unterkategorien nur anzeigen, wenn Kategorie bereits existiert -->
|
||||||
|
<div class="category_sub_select">
|
||||||
|
<!-- Container für verknüpfte Unterkategorien -->
|
||||||
|
<div class="category_sub_select_container">
|
||||||
|
<h3><?= $translation->get("connected_subcategories") ?></h3>
|
||||||
|
<!-- Suchfeld für verknüpfte Kategorien -->
|
||||||
|
<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>
|
||||||
|
<!-- Icon und Titel -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Container für verfügbare Kategorien -->
|
||||||
|
<div class="category_sub_select_container">
|
||||||
|
<h3><?= $translation->get("available_categories") ?></h3>
|
||||||
|
<!-- Suchfeld für verfügbare Kategorien -->
|
||||||
|
<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="createNavigationCheckbox">
|
||||||
|
<?= $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 if ($categoryId != 0): ?>
|
||||||
|
<div class="aside_section">
|
||||||
|
<h3><?= $translation->get("permissions_and_filters") ?></h3>
|
||||||
|
<label for="selected_mandants">
|
||||||
|
<?= $translation->get("mandants") ?>
|
||||||
|
<input type="text" id="selected_mandants" name="selected_mandants"
|
||||||
|
value='<?= htmlspecialchars(json_encode($savedMandantsData)); ?>'>
|
||||||
|
</label>
|
||||||
|
<label for="selected_departments">
|
||||||
|
<?= $translation->get("departments") ?>
|
||||||
|
<input type="text" id="selected_departments" name="selected_departments"
|
||||||
|
value='<?= htmlspecialchars(json_encode($savedDepartmentsData)); ?>'>
|
||||||
|
</label>
|
||||||
|
<label for="selected_roles">
|
||||||
|
<?= $translation->get("roles") ?>
|
||||||
|
<input type="text" id="selected_roles" name="selected_roles"
|
||||||
|
value='<?= htmlspecialchars(json_encode($savedRolesData)); ?>'>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
if (!empty($categoryId)) {
|
||||||
|
$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 } ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
|
||||||
|
|
||||||
|
$.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()
|
||||||
|
}, 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)
|
||||||
|
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; }));
|
||||||
|
|
||||||
|
$.post("/module/knowledgecenter_update/Ajax/Ajax.php", {
|
||||||
|
action: "update_category_links",
|
||||||
|
id: $("#category_id").val(),
|
||||||
|
mandants: mandants,
|
||||||
|
departments: departments,
|
||||||
|
roles: roles,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, "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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,459 @@
|
|||||||
|
<?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';
|
||||||
|
?>
|
||||||
|
<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 (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<?= button("post_list", $translation->get("post_list"), $formname, "sendRequest('PostList', true)"); ?>
|
||||||
|
<?php } ?>
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<?= button("category_list", $translation->get("category_list"), $formname, "sendRequest('Categories', true)"); ?>
|
||||||
|
<?php } ?>
|
||||||
|
<div id="changeCategoryGridView">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<a href="?action=NewPost&InitialCategory=<?= $_GET["detail"] ?>"
|
||||||
|
title="<?= $translation->get("new_wiki_post") ?>" role="button" class="green">Beitrag erstellen +</a>
|
||||||
|
<?php } ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="searchResults"></div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$placeholderCategory = '/userdata/intranet/mandant/platzhalter_kategorie.png';
|
||||||
|
$placeholderPost = '/userdata/intranet/mandant/platzhalter.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.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
|
||||||
|
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) )
|
||||||
|
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>";
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
$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
|
||||||
|
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) )
|
||||||
|
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.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
|
||||||
|
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) )
|
||||||
|
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 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>";
|
||||||
|
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>
|
||||||
270
module/knowledgecenter_update/Views/category_posts_widget.php
Normal file
270
module/knowledgecenter_update/Views/category_posts_widget.php
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<?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
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
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,403 @@
|
|||||||
|
<?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
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
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))
|
||||||
|
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>
|
||||||
1035
module/knowledgecenter_update/Views/post_cardform.php
Normal file
1035
module/knowledgecenter_update/Views/post_cardform.php
Normal file
File diff suppressed because it is too large
Load Diff
314
module/knowledgecenter_update/Views/post_cardform_article.php
Normal file
314
module/knowledgecenter_update/Views/post_cardform_article.php
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<?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): ?>
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
|
||||||
|
<span id="editBtn" class="post-action-btn grey" title="Aktuelle Version bearbeiten">✎</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1): ?>
|
||||||
|
<span id="createBtn" class="green" title="Neue Version erstellen">Neue Version erstellen +</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?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 } ?>
|
||||||
|
|
||||||
|
<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>
|
||||||
111
module/knowledgecenter_update/Views/post_cardform_contacts.php
Normal file
111
module/knowledgecenter_update/Views/post_cardform_contacts.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</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>
|
||||||
144
module/knowledgecenter_update/Views/post_cardform_edit_aside.php
Normal file
144
module/knowledgecenter_update/Views/post_cardform_edit_aside.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<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' => ''];
|
||||||
|
?>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: Sidebar with save button and meta fields -->
|
||||||
|
<div class="edit-sidebar">
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1 || get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<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' => ''];
|
||||||
|
?>
|
||||||
|
<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 (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<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 (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
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>
|
||||||
508
module/knowledgecenter_update/Views/post_cardform_settings.php
Normal file
508
module/knowledgecenter_update/Views/post_cardform_settings.php
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
<?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)
|
||||||
|
$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);
|
||||||
|
while ($row = mysqli_fetch_assoc($result)) {
|
||||||
|
// Hier wird der in der Übersetzungstabelle gespeicherte Titel als 'name' verwendet.
|
||||||
|
$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']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Produkte für Post --------------------
|
||||||
|
$productsData = [];
|
||||||
|
$query = "SELECT p.id, pt.title
|
||||||
|
FROM product AS p
|
||||||
|
JOIN product_translation AS pt
|
||||||
|
ON p.id = pt.product_id
|
||||||
|
WHERE pt.language_id = $languageId";
|
||||||
|
$result = mysqli_query($GLOBALS['mysql_con'], $query);
|
||||||
|
while ($row = mysqli_fetch_assoc($result)) {
|
||||||
|
$productsData[] = ['value' => $row['id'], 'name' => $row['title']];
|
||||||
|
}
|
||||||
|
$selectedProducts = [];
|
||||||
|
if ($postId != 0) {
|
||||||
|
$querySelected = "SELECT p.id, pt.title
|
||||||
|
FROM product_knowledgecenter_post_link AS pl
|
||||||
|
JOIN product AS p ON pl.product_id = p.id
|
||||||
|
JOIN product_translation AS pt
|
||||||
|
ON p.id = pt.product_id
|
||||||
|
WHERE pl.post_id = $postId
|
||||||
|
AND pt.language_id = $languageId";
|
||||||
|
$resultSelected = mysqli_query($GLOBALS['mysql_con'], $querySelected);
|
||||||
|
while ($row = mysqli_fetch_assoc($resultSelected)) {
|
||||||
|
$selectedProducts[] = ['value' => $row['id'], 'name' => $row['title']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
<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><?=$translation->get("products")?></h3>
|
||||||
|
<div class="settings_container">
|
||||||
|
<input type="text" id="postProductTags" name="postProducts" placeholder="<?=$translation->get("products")?>…" />
|
||||||
|
</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 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 productsData = <?php echo json_encode($productsData); ?>;
|
||||||
|
var selectedProducts = <?php echo json_encode($selectedProducts); ?>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produkte für Post
|
||||||
|
var tagifyPostProducts = new Tagify(document.getElementById('postProductTags'), {
|
||||||
|
whitelist: productsData,
|
||||||
|
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 (selectedProducts.length) {
|
||||||
|
tagifyPostProducts.addTags(selectedProducts);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<?php if (get_permission_state('r-wiki', $GLOBALS['main_contact']['master_mandant_id'], $GLOBALS['main_contact']['id']) == 1) { ?>
|
||||||
|
$("#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 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_products",
|
||||||
|
post_id: postId,
|
||||||
|
products: JSON.stringify(tagifyPostProducts.value.map(tag => tag.value))
|
||||||
|
}, "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, productsResponse, 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 productsSuccess = productsResponse[0].success;
|
||||||
|
var pdfInlineSuccess = pdfInlineResponse[0].success;
|
||||||
|
var postColorsSuccess = postColorsResponse[0].success;
|
||||||
|
|
||||||
|
if (catSuccess && mandantsSuccess && formSuccess && departmentsSuccess && rolesSuccess && productsSuccess && 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 (!productsSuccess) errorMsg += " Produkte";
|
||||||
|
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>
|
||||||
95
module/knowledgecenter_update/Views/post_listform.php
Normal file
95
module/knowledgecenter_update/Views/post_listform.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?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 (state = 1)
|
||||||
|
$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>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var myRedirectURL = window.location.href;
|
||||||
|
var debounceTimer;
|
||||||
|
|
||||||
|
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 // hier mitgeben
|
||||||
|
},
|
||||||
|
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>
|
||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
259
module/tasks/actions/admin/task_form_action.php
Normal file
259
module/tasks/actions/admin/task_form_action.php
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<?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' => [],
|
||||||
|
'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);
|
||||||
|
|
||||||
|
if ($mandantId > 0) {
|
||||||
|
$selection['mandant_ids'][] = $mandantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($departmentId > 0) {
|
||||||
|
$selection['department_ids'][] = $departmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($roleId > 0) {
|
||||||
|
$selection['role_ids'][] = $roleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
'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);
|
||||||
688
module/tasks/bootstrap.php
Normal file
688
module/tasks/bootstrap.php
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
<?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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
418
module/tasks/endpoints/admin/task_upsert.php
Normal file
418
module/tasks/endpoints/admin/task_upsert.php
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
<?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')));
|
||||||
|
|
||||||
|
$mandantValues = $mandantIds === [] ? [0] : $mandantIds;
|
||||||
|
$departmentValues = $departmentIds === [] ? [0] : $departmentIds;
|
||||||
|
$roleValues = $roleIds === [] ? [0] : $roleIds;
|
||||||
|
|
||||||
|
$rules = [];
|
||||||
|
$count = 0;
|
||||||
|
$maxRules = 5000;
|
||||||
|
|
||||||
|
foreach ($mandantValues as $mandantId) {
|
||||||
|
foreach ($departmentValues as $departmentId) {
|
||||||
|
foreach ($roleValues as $roleId) {
|
||||||
|
if ((int) $mandantId === 0 && (int) $departmentId === 0 && (int) $roleId === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules[] = [
|
||||||
|
'main_mandant_id' => (int) $mandantId ?: null,
|
||||||
|
'main_department_id' => (int) $departmentId ?: null,
|
||||||
|
'main_role_id' => (int) $roleId ?: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
module/tasks/lib/Csrf.php
Normal file
47
module/tasks/lib/Csrf.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertCsrf
|
||||||
|
{
|
||||||
|
private const TOKEN_KEY = 'task_cert_csrf_token';
|
||||||
|
|
||||||
|
public static function token(): string
|
||||||
|
{
|
||||||
|
if (empty($_SESSION[self::TOKEN_KEY])) {
|
||||||
|
$_SESSION[self::TOKEN_KEY] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $_SESSION[self::TOKEN_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hiddenInput(): string
|
||||||
|
{
|
||||||
|
return '<input type="hidden" name="csrf_token" value="' . TaskCertView::e(self::token()) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validate(?string $token): bool
|
||||||
|
{
|
||||||
|
if ($token === null || $token === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionToken = (string) ($_SESSION[self::TOKEN_KEY] ?? '');
|
||||||
|
if ($sessionToken === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($sessionToken, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assertValidFromRequest(bool $jsonResponse = true): void
|
||||||
|
{
|
||||||
|
$token = TaskCertRequest::postString('csrf_token');
|
||||||
|
if ($token === '') {
|
||||||
|
$token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::validate($token)) {
|
||||||
|
TaskCertResponse::error('CSRF validation failed', 419, $jsonResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
module/tasks/lib/Db.php
Normal file
159
module/tasks/lib/Db.php
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertDb
|
||||||
|
{
|
||||||
|
public static function conn(): mysqli
|
||||||
|
{
|
||||||
|
if (!isset($GLOBALS['mysql_con']) || !($GLOBALS['mysql_con'] instanceof mysqli)) {
|
||||||
|
throw new RuntimeException('task_cert: Missing mysqli connection in $GLOBALS["mysql_con"].');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $GLOBALS['mysql_con'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function beginTransaction(): void
|
||||||
|
{
|
||||||
|
mysqli_begin_transaction(self::conn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function commit(): void
|
||||||
|
{
|
||||||
|
mysqli_commit(self::conn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rollback(): void
|
||||||
|
{
|
||||||
|
mysqli_rollback(self::conn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchAll(string $sql, string $types = '', array $params = []): array
|
||||||
|
{
|
||||||
|
if ($types === '' && $params === []) {
|
||||||
|
$result = mysqli_query(self::conn(), $sql);
|
||||||
|
if ($result === false) {
|
||||||
|
throw new RuntimeException('task_cert query failed: ' . mysqli_error(self::conn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result instanceof mysqli_result) {
|
||||||
|
$rows = mysqli_fetch_all($result, MYSQLI_ASSOC);
|
||||||
|
mysqli_free_result($result);
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = self::prepareAndBind($sql, $types, $params);
|
||||||
|
mysqli_stmt_execute($stmt);
|
||||||
|
$result = mysqli_stmt_get_result($stmt);
|
||||||
|
|
||||||
|
if ($result === false) {
|
||||||
|
$error = mysqli_stmt_error($stmt);
|
||||||
|
mysqli_stmt_close($stmt);
|
||||||
|
throw new RuntimeException('task_cert fetchAll failed: ' . $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = mysqli_fetch_all($result, MYSQLI_ASSOC);
|
||||||
|
mysqli_free_result($result);
|
||||||
|
mysqli_stmt_close($stmt);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchOne(string $sql, string $types = '', array $params = []): ?array
|
||||||
|
{
|
||||||
|
$rows = self::fetchAll($sql, $types, $params);
|
||||||
|
if ($rows === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function scalar(string $sql, string $types = '', array $params = [])
|
||||||
|
{
|
||||||
|
$row = self::fetchOne($sql, $types, $params);
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($row as $value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function execute(string $sql, string $types = '', array $params = []): int
|
||||||
|
{
|
||||||
|
if ($types === '' && $params === []) {
|
||||||
|
$ok = mysqli_query(self::conn(), $sql);
|
||||||
|
if ($ok === false) {
|
||||||
|
throw new RuntimeException('task_cert execute failed: ' . mysqli_error(self::conn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mysqli_affected_rows(self::conn());
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = self::prepareAndBind($sql, $types, $params);
|
||||||
|
mysqli_stmt_execute($stmt);
|
||||||
|
$affected = mysqli_stmt_affected_rows($stmt);
|
||||||
|
$error = mysqli_stmt_error($stmt);
|
||||||
|
mysqli_stmt_close($stmt);
|
||||||
|
|
||||||
|
if ($error !== '') {
|
||||||
|
throw new RuntimeException('task_cert execute failed: ' . $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function insert(string $sql, string $types, array $params): int
|
||||||
|
{
|
||||||
|
$stmt = self::prepareAndBind($sql, $types, $params);
|
||||||
|
mysqli_stmt_execute($stmt);
|
||||||
|
$error = mysqli_stmt_error($stmt);
|
||||||
|
mysqli_stmt_close($stmt);
|
||||||
|
|
||||||
|
if ($error !== '') {
|
||||||
|
throw new RuntimeException('task_cert insert failed: ' . $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) mysqli_insert_id(self::conn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function prepareAndBind(string $sql, string $types, array $params): mysqli_stmt
|
||||||
|
{
|
||||||
|
$stmt = mysqli_prepare(self::conn(), $sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new RuntimeException('task_cert prepare failed: ' . mysqli_error(self::conn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::bindParams($stmt, $types, $params);
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bindParams(mysqli_stmt $stmt, string $types, array $params): void
|
||||||
|
{
|
||||||
|
if ($types === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = array_values($params);
|
||||||
|
if (strlen($types) !== count($params)) {
|
||||||
|
throw new InvalidArgumentException('task_cert bind mismatch: number of types != number of params');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindValues = [$types];
|
||||||
|
foreach ($params as $index => $value) {
|
||||||
|
$bindValues[] = &$params[$index];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = call_user_func_array([$stmt, 'bind_param'], $bindValues);
|
||||||
|
if ($ok === false) {
|
||||||
|
throw new RuntimeException('task_cert bind_param failed: ' . mysqli_stmt_error($stmt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
module/tasks/lib/Html.php
Normal file
47
module/tasks/lib/Html.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertHtml
|
||||||
|
{
|
||||||
|
private const ALLOWED_TAGS = '<p><br><strong><em><u><h2><h3><ol><ul><li><a>';
|
||||||
|
|
||||||
|
public static function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
$html = trim($html);
|
||||||
|
if ($html === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip all tags except the whitelist
|
||||||
|
$html = strip_tags($html, self::ALLOWED_TAGS);
|
||||||
|
|
||||||
|
// Sanitize <a> tags: keep only href, add target="_blank" and rel="noopener"
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'/<a\s[^>]*>/i',
|
||||||
|
static function (array $match): string {
|
||||||
|
$tag = $match[0];
|
||||||
|
// Extract href value
|
||||||
|
if (preg_match('/href\s*=\s*"([^"]*)"/i', $tag, $hrefMatch)) {
|
||||||
|
$href = $hrefMatch[1];
|
||||||
|
// Block javascript: URLs
|
||||||
|
if (preg_match('/^\s*javascript\s*:/i', $href)) {
|
||||||
|
return '<a>';
|
||||||
|
}
|
||||||
|
return '<a href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener">';
|
||||||
|
}
|
||||||
|
return '<a>';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
) ?? $html;
|
||||||
|
|
||||||
|
// Remove empty paragraphs that Quill sometimes produces
|
||||||
|
$html = preg_replace('/<p>\s*<br\s*\/?>\s*<\/p>/', '', $html) ?? $html;
|
||||||
|
|
||||||
|
return trim($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isHtml(string $text): bool
|
||||||
|
{
|
||||||
|
return $text !== strip_tags($text);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
module/tasks/lib/Logger.php
Normal file
117
module/tasks/lib/Logger.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertLogger
|
||||||
|
{
|
||||||
|
private static ?string $logFile = null;
|
||||||
|
|
||||||
|
public static function debug(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('DEBUG', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function info(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('INFO', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function warning(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('WARNING', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
self::write('ERROR', $message, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function write(string $level, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$line = self::formatLine($level, $message, $context);
|
||||||
|
$logFile = self::resolveLogFile();
|
||||||
|
$logDir = dirname($logFile);
|
||||||
|
|
||||||
|
if (!is_dir($logDir)) {
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||||
|
error_log(trim($line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveLogFile(): string
|
||||||
|
{
|
||||||
|
if (self::$logFile !== null) {
|
||||||
|
return self::$logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configured = trim((string) (getenv('TASK_CERT_LOG_FILE') ?: ($_ENV['TASK_CERT_LOG_FILE'] ?? '')));
|
||||||
|
if ($configured === '') {
|
||||||
|
self::$logFile = dirname(__DIR__) . '/logs/task_cert.log';
|
||||||
|
return self::$logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configured[0] === '/') {
|
||||||
|
self::$logFile = $configured;
|
||||||
|
return self::$logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$logFile = dirname(__DIR__) . '/' . ltrim($configured, '/');
|
||||||
|
return self::$logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatLine(string $level, string $message, array $context): string
|
||||||
|
{
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$normalizedContext = self::normalizeContext($context);
|
||||||
|
|
||||||
|
if ($normalizedContext !== []) {
|
||||||
|
$contextJson = json_encode($normalizedContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($contextJson !== false) {
|
||||||
|
return sprintf("[%s] task_cert.%s %s | %s\n", $timestamp, strtoupper($level), $message, $contextJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf("[%s] task_cert.%s %s\n", $timestamp, strtoupper($level), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeContext(array $context): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($context as $key => $value) {
|
||||||
|
$stringKey = (string) $key;
|
||||||
|
|
||||||
|
if (is_scalar($value) || $value === null) {
|
||||||
|
$normalized[$stringKey] = $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof Throwable) {
|
||||||
|
$normalized[$stringKey] = [
|
||||||
|
'class' => get_class($value),
|
||||||
|
'message' => $value->getMessage(),
|
||||||
|
'file' => $value->getFile(),
|
||||||
|
'line' => $value->getLine(),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$normalized[$stringKey] = $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_object($value)) {
|
||||||
|
$normalized[$stringKey] = [
|
||||||
|
'class' => get_class($value),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$stringKey] = gettype($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
module/tasks/lib/Request.php
Normal file
92
module/tasks/lib/Request.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertRequest
|
||||||
|
{
|
||||||
|
public static function method(): string
|
||||||
|
{
|
||||||
|
return strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isPost(): bool
|
||||||
|
{
|
||||||
|
return self::method() === 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function postString(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$value = $_POST[$key] ?? $default;
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function postInt(string $key, int $default = 0): int
|
||||||
|
{
|
||||||
|
return (int) ($_POST[$key] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function postArray(string $key): array
|
||||||
|
{
|
||||||
|
$value = $_POST[$key] ?? [];
|
||||||
|
return is_array($value) ? $value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function postIntArray(string $key): array
|
||||||
|
{
|
||||||
|
$items = self::postArray($key);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$collect = static function ($value) use (&$collect, &$result): void {
|
||||||
|
if (is_array($value)) {
|
||||||
|
foreach ($value as $nestedValue) {
|
||||||
|
$collect($nestedValue);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemInt = (int) $value;
|
||||||
|
if ($itemInt > 0) {
|
||||||
|
$result[] = $itemInt;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$collect($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function queryString(string $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$value = $_GET[$key] ?? $default;
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function queryInt(string $key, int $default = 0): int
|
||||||
|
{
|
||||||
|
return (int) ($_GET[$key] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function jsonBody(): array
|
||||||
|
{
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
module/tasks/lib/Response.php
Normal file
55
module/tasks/lib/Response.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertResponse
|
||||||
|
{
|
||||||
|
public static function json(array $payload, int $statusCode = 200, bool $terminate = true): void
|
||||||
|
{
|
||||||
|
$isFailurePayload = isset($payload['success']) && (bool) $payload['success'] === false;
|
||||||
|
if ($statusCode >= 400 || $isFailurePayload) {
|
||||||
|
TaskCertLogger::warning('JSON response with error status', [
|
||||||
|
'status_code' => $statusCode,
|
||||||
|
'request_method' => (string) ($_SERVER['REQUEST_METHOD'] ?? ''),
|
||||||
|
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||||
|
'error' => (string) ($payload['error'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if ($terminate) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirect(string $url, int $statusCode = 302): void
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Location: ' . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, int $statusCode = 400, bool $asJson = true): void
|
||||||
|
{
|
||||||
|
TaskCertLogger::warning('HTTP error response', [
|
||||||
|
'status_code' => $statusCode,
|
||||||
|
'request_method' => (string) ($_SERVER['REQUEST_METHOD'] ?? ''),
|
||||||
|
'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||||
|
'as_json' => $asJson ? 1 : 0,
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($asJson) {
|
||||||
|
self::json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message,
|
||||||
|
], $statusCode, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code($statusCode);
|
||||||
|
echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
module/tasks/lib/TaskCertConstants.php
Normal file
107
module/tasks/lib/TaskCertConstants.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertConstants
|
||||||
|
{
|
||||||
|
public const ASSIGNMENT_STATUSES = [
|
||||||
|
'open', 'in_progress', 'approval_pending', 'overdue', 'completed', 'rejected', 'expired',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const COMPLETED_STATUSES = ['completed', 'expired'];
|
||||||
|
|
||||||
|
public const SUBMISSION_STATUSES = [
|
||||||
|
'submitted', 'auto_passed', 'awaiting_approval', 'approved', 'rejected',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const METHOD_TYPES = [
|
||||||
|
'knowledge_read', 'form_submit', 'quiz', 'manual_approval',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const FIELD_TYPES = [
|
||||||
|
'text', 'textarea', 'number', 'date', 'select', 'checkbox', 'radio', 'file',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const CERTIFICATE_STATUSES = ['valid', 'expired', 'revoked'];
|
||||||
|
|
||||||
|
public static function assignmentStatusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'open' => 'Offen',
|
||||||
|
'in_progress' => 'In Bearbeitung',
|
||||||
|
'approval_pending' => 'Warten auf Freigabe',
|
||||||
|
'overdue' => 'Überfällig',
|
||||||
|
'completed' => 'Abgeschlossen',
|
||||||
|
'rejected' => 'Bitte erneut einreichen',
|
||||||
|
'expired' => 'Abgelaufen',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function submissionStatusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'submitted' => 'Eingereicht',
|
||||||
|
'auto_passed' => 'Erledigt',
|
||||||
|
'awaiting_approval' => 'Wartet auf Freigabe',
|
||||||
|
'approved' => 'Freigegeben',
|
||||||
|
'rejected' => 'Abgelehnt',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function methodLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'knowledge_read' => 'Wissensbeiträge lesen',
|
||||||
|
'form_submit' => 'Formular ausfüllen',
|
||||||
|
'quiz' => 'Quiz beantworten',
|
||||||
|
'manual_approval' => 'Antrag auf Freigabe',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function certificateStatusLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'valid' => 'Gültig',
|
||||||
|
'expired' => 'Abgelaufen',
|
||||||
|
'revoked' => 'Widerrufen',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fieldTypeLabels(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'text' => 'Text',
|
||||||
|
'textarea' => 'Textbereich',
|
||||||
|
'number' => 'Zahl',
|
||||||
|
'date' => 'Datum',
|
||||||
|
'select' => 'Auswahl',
|
||||||
|
'checkbox' => 'Checkbox',
|
||||||
|
'radio' => 'Radio',
|
||||||
|
'file' => 'Datei-Upload',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidAssignmentStatus(string $status): bool
|
||||||
|
{
|
||||||
|
return in_array($status, self::ASSIGNMENT_STATUSES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidSubmissionStatus(string $status): bool
|
||||||
|
{
|
||||||
|
return in_array($status, self::SUBMISSION_STATUSES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidMethodType(string $type): bool
|
||||||
|
{
|
||||||
|
return in_array($type, self::METHOD_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidFieldType(string $type): bool
|
||||||
|
{
|
||||||
|
return in_array($type, self::FIELD_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isValidCertificateStatus(string $status): bool
|
||||||
|
{
|
||||||
|
return in_array($status, self::CERTIFICATE_STATUSES, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
module/tasks/lib/TaskUploadPolicy.php
Normal file
160
module/tasks/lib/TaskUploadPolicy.php
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Regeln für Datei-Uploads in Formular-Feldern vom Typ "file".
|
||||||
|
*
|
||||||
|
* Eine einzige Quelle der Wahrheit für erlaubte Extensions, MIME-Prefixe und
|
||||||
|
* Limits. Das Nutzer-Formular liest diese Werte (accept-Attribut, Hinweistext),
|
||||||
|
* der Server validiert mit denselben Regeln. Bewusst dokumenttypen-lastig —
|
||||||
|
* in der Praxis sind Uploads PDFs, Screenshots und Office-Dateien.
|
||||||
|
*
|
||||||
|
* Modul-lokal gehalten (kein use auf module/tickets), weil module/tasks keinen
|
||||||
|
* Namespace verwendet und beide Module entkoppelt bleiben sollen.
|
||||||
|
*/
|
||||||
|
class TaskUploadPolicy
|
||||||
|
{
|
||||||
|
public const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||||
|
public const MAX_FILE_COUNT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions werden case-insensitiv gegen das Dateinamen-Suffix geprüft.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const ALLOWED_EXTENSIONS = [
|
||||||
|
'pdf',
|
||||||
|
'png', 'jpg', 'jpeg', 'webp',
|
||||||
|
'doc', 'docx',
|
||||||
|
'xls', 'xlsx',
|
||||||
|
'ppt', 'pptx',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weiche MIME-Plausibilitätsprüfung. Wir lehnen nur ab, wenn fileinfo
|
||||||
|
* verfügbar ist UND der erkannte Typ zu keinem Prefix passt. Fehlt fileinfo,
|
||||||
|
* überspringt der Storage diesen Check, damit legitime Uploads nicht blind
|
||||||
|
* blockiert werden.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const ALLOWED_MIME_PREFIXES = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const HUMAN_READABLE_FORMATS_SHORT = 'PDF, Office, Bilder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministisch sortiertes accept="…"-Attribut für <input type="file">.
|
||||||
|
*/
|
||||||
|
public static function acceptAttribute(): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
foreach (self::ALLOWED_EXTENSIONS as $ext) {
|
||||||
|
$parts[] = '.' . $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(',', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministische, sichere MIME-Zuordnung je erlaubter Extension. Wird
|
||||||
|
* sowohl beim Speichern als auch beim Ausliefern genutzt — der vom Client
|
||||||
|
* gemeldete MIME-Typ wird bewusst NIE verwendet (sonst Stored-XSS, z. B.
|
||||||
|
* als Bild getarntes SVG mit Skript). Nicht zuordenbare Endungen landen bei
|
||||||
|
* application/octet-stream (Download statt Inline-Render).
|
||||||
|
*
|
||||||
|
* @var array<string,string>
|
||||||
|
*/
|
||||||
|
public const EXTENSION_MIME_MAP = [
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'doc' => 'application/msword',
|
||||||
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'xls' => 'application/vnd.ms-excel',
|
||||||
|
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'ppt' => 'application/vnd.ms-powerpoint',
|
||||||
|
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME-Typen, die gefahrlos inline im Browser angezeigt werden dürfen.
|
||||||
|
* Bewusst ohne image/svg+xml, text/*, application/xml o. Ä.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const INLINE_SAFE_MIMES = [
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function mimeForFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$dot = strrpos($filename, '.');
|
||||||
|
if ($dot !== false) {
|
||||||
|
$ext = strtolower(substr($filename, $dot + 1));
|
||||||
|
if (isset(self::EXTENSION_MIME_MAP[$ext])) {
|
||||||
|
return self::EXTENSION_MIME_MAP[$ext];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isInlineSafeMime(string $mime): bool
|
||||||
|
{
|
||||||
|
return in_array($mime, self::INLINE_SAFE_MIMES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isExtensionAllowed(string $filename): bool
|
||||||
|
{
|
||||||
|
$dot = strrpos($filename, '.');
|
||||||
|
if ($dot === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(substr($filename, $dot + 1));
|
||||||
|
|
||||||
|
return in_array($ext, self::ALLOWED_EXTENSIONS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isMimeAllowedIfDetected(?string $mime): bool
|
||||||
|
{
|
||||||
|
if ($mime === null || $mime === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::ALLOWED_MIME_PREFIXES as $prefix) {
|
||||||
|
if (strpos($mime, $prefix) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes < 1024) {
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes < 1024 * 1024) {
|
||||||
|
return number_format($bytes / 1024, 1, ',', '.') . ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($bytes / (1024 * 1024), 1, ',', '.') . ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
240
module/tasks/lib/TaskUploadStorage.php
Normal file
240
module/tasks/lib/TaskUploadStorage.php
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt hochgeladene Dateien für Formular-Felder vom Typ "file" entgegen,
|
||||||
|
* validiert sie gegen TaskUploadPolicy und legt sie unter
|
||||||
|
* userdata/tasks/submissions/{Y}/{m}/ ab.
|
||||||
|
*
|
||||||
|
* Gibt pro Datei Metadaten zurück (Originalname, relativer Pfad, MIME, Größe),
|
||||||
|
* die im task_submission.payload_json gespeichert werden. Der relative Pfad
|
||||||
|
* (z. B. "tasks/submissions/2026/06/tsk_….pdf") ist die einzige Referenz, die
|
||||||
|
* der Download-Endpoint später auflöst.
|
||||||
|
*/
|
||||||
|
class TaskUploadStorage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Wandelt $_FILES['files'] (verschachtelt nach field_key) in eine
|
||||||
|
* pro-Feld-Struktur um:
|
||||||
|
* ['field_key' => [ ['name'=>,'type'=>,'tmp_name'=>,'error'=>,'size'=>], … ]]
|
||||||
|
*
|
||||||
|
* PHP liefert für name="files[field_key][]" die Form
|
||||||
|
* $_FILES['files']['name'][field_key][i] usw.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $rawFiles Der $_FILES['files']-Eintrag
|
||||||
|
* @return array<string,array<int,array<string,mixed>>>
|
||||||
|
*/
|
||||||
|
public static function normalizeFilesInput(array $rawFiles): array
|
||||||
|
{
|
||||||
|
$names = $rawFiles['name'] ?? null;
|
||||||
|
if (!is_array($names)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($names as $fieldKey => $fieldNames) {
|
||||||
|
$fieldKey = (string) $fieldKey;
|
||||||
|
if (!is_array($fieldNames)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
foreach (array_keys($fieldNames) as $i) {
|
||||||
|
$entries[] = [
|
||||||
|
'name' => (string) ($rawFiles['name'][$fieldKey][$i] ?? ''),
|
||||||
|
'type' => (string) ($rawFiles['type'][$fieldKey][$i] ?? ''),
|
||||||
|
'tmp_name' => (string) ($rawFiles['tmp_name'][$fieldKey][$i] ?? ''),
|
||||||
|
'error' => (int) ($rawFiles['error'][$fieldKey][$i] ?? UPLOAD_ERR_NO_FILE),
|
||||||
|
'size' => (int) ($rawFiles['size'][$fieldKey][$i] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$fieldKey] = $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert und speichert die Dateien eines einzelnen file-Feldes.
|
||||||
|
*
|
||||||
|
* @param array<int,array<string,mixed>> $fieldFiles Normalisierte Datei-Einträge
|
||||||
|
* @return array<int,array<string,mixed>> Metadaten je gespeicherter Datei
|
||||||
|
* @throws RuntimeException bei Validierungs- oder Speicherfehlern
|
||||||
|
*/
|
||||||
|
public static function storeFiles(int $assignmentId, int $methodId, string $fieldKey, array $fieldFiles): array
|
||||||
|
{
|
||||||
|
// Leere Slots (kein Upload) herausfiltern.
|
||||||
|
$uploads = [];
|
||||||
|
foreach ($fieldFiles as $file) {
|
||||||
|
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||||
|
if ($error === UPLOAD_ERR_NO_FILE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Upload fehlgeschlagen (Fehlercode ' . $error . ').');
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploads[] = $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uploads === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($uploads) > TaskUploadPolicy::MAX_FILE_COUNT) {
|
||||||
|
throw new RuntimeException('Maximal ' . TaskUploadPolicy::MAX_FILE_COUNT . ' Dateien pro Feld erlaubt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBytes = 0;
|
||||||
|
foreach ($uploads as $file) {
|
||||||
|
$totalBytes += (int) ($file['size'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($totalBytes > TaskUploadPolicy::MAX_TOTAL_BYTES) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Die Dateien überschreiten das Gesamtlimit von '
|
||||||
|
. TaskUploadPolicy::formatBytes(TaskUploadPolicy::MAX_TOTAL_BYTES) . '.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetDir = self::targetDir();
|
||||||
|
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||||
|
throw new RuntimeException('Zielverzeichnis für Uploads konnte nicht angelegt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$finfo = function_exists('finfo_open') ? finfo_open(FILEINFO_MIME_TYPE) : false;
|
||||||
|
|
||||||
|
$stored = [];
|
||||||
|
try {
|
||||||
|
foreach ($uploads as $file) {
|
||||||
|
$originalName = (string) ($file['name'] ?? '');
|
||||||
|
$tmpName = (string) ($file['tmp_name'] ?? '');
|
||||||
|
|
||||||
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
|
throw new RuntimeException('Ungültiger Upload für "' . $originalName . '".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TaskUploadPolicy::isExtensionAllowed($originalName)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Dateityp nicht erlaubt: "' . $originalName . '". Erlaubt sind '
|
||||||
|
. TaskUploadPolicy::HUMAN_READABLE_FORMATS_SHORT . '.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$detectedMime = $finfo !== false ? (finfo_file($finfo, $tmpName) ?: null) : null;
|
||||||
|
if (!TaskUploadPolicy::isMimeAllowedIfDetected($detectedMime)) {
|
||||||
|
throw new RuntimeException('Dateiinhalt nicht erlaubt für "' . $originalName . '".');
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $originalName) ?? '';
|
||||||
|
$safeName = trim($safeName, '_');
|
||||||
|
if ($safeName === '') {
|
||||||
|
$safeName = 'datei';
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedName = uniqid('tsk_', true) . '__' . $safeName;
|
||||||
|
$absolutePath = $targetDir . '/' . $storedName;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($tmpName, $absolutePath)) {
|
||||||
|
throw new RuntimeException('Datei "' . $originalName . '" konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($absolutePath, 0664);
|
||||||
|
|
||||||
|
$stored[] = [
|
||||||
|
'file_name' => $originalName !== '' ? $originalName : $safeName,
|
||||||
|
'file_path' => self::relativeSubdir() . '/' . $storedName,
|
||||||
|
// MIME deterministisch aus der (erlaubten) Endung — NIE der
|
||||||
|
// Client-MIME. $detectedMime dient nur der Validierung oben.
|
||||||
|
'mime_type' => TaskUploadPolicy::mimeForFilename($originalName),
|
||||||
|
'file_size' => (int) ($file['size'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
// Bereits verschobene Dateien dieses Feldes wieder entfernen, damit
|
||||||
|
// bei einem Teil-Fehler keine verwaisten Dateien zurückbleiben.
|
||||||
|
foreach ($stored as $meta) {
|
||||||
|
$path = self::absolutePath((string) $meta['file_path']);
|
||||||
|
if ($path !== null && is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finfo !== false) {
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finfo !== false) {
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht die zu den relativen Pfaden gehörenden Dateien von der Platte.
|
||||||
|
* Ungültige Pfade (Traversal, außerhalb des Submissions-Ordners) und bereits
|
||||||
|
* fehlende Dateien werden still übersprungen. Gibt die Anzahl tatsächlich
|
||||||
|
* gelöschter Dateien zurück.
|
||||||
|
*
|
||||||
|
* @param string[] $relativePaths
|
||||||
|
*/
|
||||||
|
public static function deleteFiles(array $relativePaths): int
|
||||||
|
{
|
||||||
|
$deleted = 0;
|
||||||
|
foreach (array_unique($relativePaths) as $relativePath) {
|
||||||
|
$absolutePath = self::absolutePath((string) $relativePath);
|
||||||
|
if ($absolutePath !== null && is_file($absolutePath) && @unlink($absolutePath)) {
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst einen gespeicherten relativen Pfad sicher in einen Absolutpfad auf.
|
||||||
|
* Gibt null zurück, wenn der Pfad außerhalb des Submissions-Ordners liegt
|
||||||
|
* oder Traversal enthält.
|
||||||
|
*/
|
||||||
|
public static function absolutePath(string $relativePath): ?string
|
||||||
|
{
|
||||||
|
$relativePath = ltrim(str_replace('\\', '/', trim($relativePath)), '/');
|
||||||
|
|
||||||
|
if ($relativePath === '' || strpos($relativePath, '..') !== false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($relativePath, 'tasks/submissions/') !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::userdataDir() . '/' . $relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function userdataDir(): string
|
||||||
|
{
|
||||||
|
if (!defined('TASK_CERT_USERDATA_DIR')) {
|
||||||
|
throw new RuntimeException('TASK_CERT_USERDATA_DIR ist nicht definiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim((string) TASK_CERT_USERDATA_DIR, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function relativeSubdir(): string
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
|
||||||
|
return 'tasks/submissions/' . $now->format('Y') . '/' . $now->format('m');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function targetDir(): string
|
||||||
|
{
|
||||||
|
return self::userdataDir() . '/' . self::relativeSubdir();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
module/tasks/lib/View.php
Normal file
20
module/tasks/lib/View.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskCertView
|
||||||
|
{
|
||||||
|
public static function renderPartial(string $pageFile, array $viewData = []): void
|
||||||
|
{
|
||||||
|
if (!file_exists($pageFile)) {
|
||||||
|
throw new RuntimeException('task_cert view not found: ' . $pageFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
extract($viewData, EXTR_SKIP);
|
||||||
|
require $pageFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function e($value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"pid":2314430,"started_at":"2026-06-22T07:00:02+00:00"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"pid":2314429,"started_at":"2026-06-22T07:00:02+00:00"}
|
||||||
1
module/tasks/logs/locks/task_cert_sync_assignments.lock
Normal file
1
module/tasks/logs/locks/task_cert_sync_assignments.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"pid":2290462,"started_at":"2026-06-22T02:00:01+00:00"}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user