diff --git a/DeskHub-Webseite/staff/custom_fields.php b/DeskHub-Webseite/staff/custom_fields.php index fe3d449d..de9bac20 100644 --- a/DeskHub-Webseite/staff/custom_fields.php +++ b/DeskHub-Webseite/staff/custom_fields.php @@ -8,6 +8,7 @@ $fieldTypes = [ 'text' => 'Einzeiliger Text', 'textarea' => 'Mehrzeiliger Text', 'select' => 'Dropdown-Auswahl', + 'radio' => 'Radio-Auswahl (Punkte)', 'checkbox' => 'Checkbox (Ja/Nein)', 'number' => 'Zahl', 'date' => 'Datum', @@ -25,20 +26,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $isRequired = !empty($_POST['is_required']) ? 1 : 0; $isActive = !empty($_POST['is_active']) ? 1 : 0; $sort = (int) ($_POST['sort_order'] ?? 0); + $radioInline = !empty($_POST['radio_inline']) ? 1 : 0; + $helpText = form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? ''), $label); if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.'; - if ($type === 'select' && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).'; + if (in_array($type, ['select', 'radio'], true) && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).'; if (!$errors) { if ($action === 'create') { - $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?)') - ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, db_now()]); + $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, radio_inline, help_text, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, db_now()]); audit_log($admin['id'], "Ticket-Feld erstellt: {$label}"); flash('success', 'Feld erstellt.'); } else { $id = (int) $_POST['id']; - $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=? WHERE id=?') - ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $id]); + $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=?, radio_inline=?, help_text=? WHERE id=?') + ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $radioInline, $helpText ?: null, $id]); audit_log($admin['id'], "Ticket-Feld aktualisiert: {$label}"); flash('success', 'Feld aktualisiert.'); } @@ -61,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $fields = $pdo->query( 'SELECT cf.*, ht.name AS help_topic_name FROM custom_fields cf LEFT JOIN help_topics ht ON ht.id = cf.help_topic_id - ORDER BY cf.sort_order, cf.label' + WHERE cf.department_id IS NULL ORDER BY cf.sort_order, cf.label' )->fetchAll(); $helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll(); @@ -128,17 +131,36 @@ require __DIR__ . '/_header.php'; Feldtyp - + $lbl): ?> >= e($lbl) ?> - + Optionen (eine pro Zeile) = e($editField['options'] ?? '') ?> + + + Anordnung der Optionen + + >Untereinander + >Nebeneinander + + + + + + Hinweistext (optional) + = e($__hPlain) ?> + + Verlinkter Teil des Textes + Link-Adresse + + Bei einer Checkbox steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als Pflichtfeld muss angehakt werden. + Gilt für Hilfe-Thema Alle Themen (oder Formular ohne Themenauswahl) diff --git a/DeskHub-Webseite/staff/department_form.php b/DeskHub-Webseite/staff/department_form.php new file mode 100644 index 00000000..e0a8c76b --- /dev/null +++ b/DeskHub-Webseite/staff/department_form.php @@ -0,0 +1,289 @@ +prepare('SELECT * FROM departments WHERE id = ?'); +$stmt->execute([$deptId]); +$dept = $stmt->fetch(); +if (!$dept) { + flash('error', 'Abteilung nicht gefunden.'); + redirect('/staff/departments.php'); +} + +$fieldTypes = [ + 'text' => 'Einzeiliger Text', + 'textarea' => 'Mehrzeiliger Text', + 'select' => 'Dropdown-Auswahl', + 'radio' => 'Radio-Auswahl (Punkte)', + 'checkbox' => 'Checkbox (Ja/Nein)', + 'number' => 'Zahl', + 'date' => 'Datum', +]; +$errors = []; +// Feldpositionen: Schlüssel => [Gruppe, Beschriftung]; "@"-Schlüssel stehen in custom_fields.form_section +$positions = [ + '@topic_top' => ['Abschnitt 1 – Themenauswahl', 'vor der Themenauswahl'], + '@topic' => ['Abschnitt 1 – Themenauswahl', 'nach der Themenauswahl'], + '@contact_top' => ['Abschnitt 2 – Kontaktdaten', 'vor Name/E-Mail'], + '@contact' => ['Abschnitt 2 – Kontaktdaten', 'nach Name/E-Mail'], + '@details_top' => ['Abschnitt 3 – Betreff und Beschreibung', 'vor dem Betreff'], + '@details_mid' => ['Abschnitt 3 – Betreff und Beschreibung', 'zwischen Betreff und Beschreibung'], + '@details' => ['Abschnitt 3 – Betreff und Beschreibung', 'nach der Beschreibung'], +]; +$self = '/staff/department_form.php?dept=' . $deptId; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = $_POST['action'] ?? ''; + + if ($action === 'save_form') { + $clip = fn(string $k, int $max) => mb_substr(trim((string) ($_POST[$k] ?? '')), 0, $max); + $cfg = [ + 'title' => $clip('title', 150), + 'contact_title' => $clip('contact_title', 100), + 'issue_title' => $clip('issue_title', 100), + 'details_title' => $clip('details_title', 100), + 'intro' => $clip('intro', 1000), + 'subject_show' => !empty($_POST['subject_show']), + 'subject_label' => $clip('subject_label', 100), + 'message_label' => $clip('message_label', 100), + 'message_show' => !empty($_POST['message_show']), + 'subject_width' => in_array($_POST['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['subject_width'] : 'full', + 'message_width' => in_array($_POST['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['message_width'] : 'full', + 'message_hint' => $clip('message_hint', 200), + 'attachment_show' => !empty($_POST['attachment_show']), + 'submit_label' => $clip('submit_label', 60), + ]; + $pdo->prepare('UPDATE departments SET own_form = ?, form_config = ?, updated_at = ? WHERE id = ?') + ->execute([!empty($_POST['own_form']) ? 1 : 0, json_encode($cfg, JSON_UNESCAPED_UNICODE), db_now(), $deptId]); + audit_log($staff['id'], 'Abteilungsformular gespeichert: ' . $dept['name']); + flash('success', 'Formular gespeichert.'); + redirect($self); + } + + if ($action === 'save_field') { + $fid = (int) ($_POST['id'] ?? 0); + $label = trim((string) ($_POST['label'] ?? '')); + $type = array_key_exists($_POST['field_type'] ?? '', $fieldTypes) ? $_POST['field_type'] : 'text'; + $options = trim((string) ($_POST['options'] ?? '')); + $required = !empty($_POST['is_required']) ? 1 : 0; + $active = !empty($_POST['is_active']) ? 1 : 0; + $sort = (int) ($_POST['sort_order'] ?? 0); + $radioInline = !empty($_POST['radio_inline']) ? 1 : 0; + $helpText = form_help_compose((string) ($_POST['help_text'] ?? ''), (string) ($_POST['link_url'] ?? ''), (string) ($_POST['link_word'] ?? ''), $label); + $width = in_array($_POST['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $_POST['form_width'] : null; + $secPick = (string) ($_POST['form_section'] ?? ''); + $section = ($secPick === '' || isset($positions[$secPick])) ? $secPick : mb_substr(trim((string) ($_POST['form_section_custom'] ?? '')), 0, 100); + if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.'; + if (in_array($type, ['select', 'radio'], true) && $options === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).'; + if (!$errors) { + if ($fid) { + $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, is_required=?, is_active=?, sort_order=?, form_section=?, form_width=?, radio_inline=?, help_text=? WHERE id=? AND department_id=?') + ->execute([$label, $type, $options ?: null, $required, $active, $sort, $section ?: null, $width, $radioInline, $helpText ?: null, $fid, $deptId]); + } else { + $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, department_id, is_required, is_active, sort_order, form_section, form_width, radio_inline, help_text, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute([$label, $type, $options ?: null, null, $deptId, $required, $active, $sort, $section ?: null, $width, $radioInline, $helpText ?: null, db_now()]); + } + audit_log($staff['id'], "Abteilungsfeld gespeichert ({$dept['name']}): {$label}"); + flash('success', 'Feld gespeichert.'); + redirect($self); + } + } + + if ($action === 'delete_field') { + $pdo->prepare('DELETE FROM custom_fields WHERE id = ? AND department_id = ?')->execute([(int) ($_POST['id'] ?? 0), $deptId]); + audit_log($staff['id'], "Abteilungsfeld gelöscht ({$dept['name']})"); + flash('success', 'Feld gelöscht (bereits erfasste Werte in Tickets bleiben erhalten).'); + redirect($self); + } +} + +$cfg = json_decode((string) ($dept['form_config'] ?? ''), true); +$cfg = is_array($cfg) ? $cfg : []; +$cv = fn(string $k, string $def = '') => (string) ($cfg[$k] ?? $def); +$flag = fn(string $k) => !array_key_exists($k, $cfg) || !empty($cfg[$k]); + +$fieldsStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? ORDER BY sort_order, label'); +$fieldsStmt->execute([$deptId]); +$fields = $fieldsStmt->fetchAll(); +$editField = null; +if (!empty($_GET['edit'])) { + foreach ($fields as $f) if ((int) $f['id'] === (int) $_GET['edit']) $editField = $f; +} + +$pageTitle = 'Formular: ' . $dept['name']; +$activePage = 'departments'; +require __DIR__ . '/_header.php'; +?> + +← Zurück zur Abteilung +Ticket-Formular — = e($dept['name']) ?> +Hier legst du fest, wie das Formular „Neues Ticket“ aussieht, wenn ein Kunde diese Abteilung wählt (direkt oder über ein Hilfe-Thema dieser Abteilung). + += e($err) ?> + + + = csrf_field() ?> + + + + + > + Eigenes Formular für diese Abteilung verwenden + + + Ist das aktiv, erscheinen für diese Abteilung nur die unten festgelegten Felder. Die allgemeinen Ticket-Felder und die Felder der Hilfe-Themen werden dann nicht verwendet. + Name und E-Mail-Adresse werden immer abgefragt, damit wir antworten können. + + + + Überschrift (optional) + Text des Absende-Knopfs + + + Überschrift Abschnitt „Kontaktdaten“ + Überschrift Abschnitt 1 (Themenauswahl) + + Überschrift Abschnitt „Betreff und Beschreibung“ + + Einleitungstext (optional) + = e($cv('intro')) ?> + + Standardfelder + > Betreff abfragen + Ohne Betrefffeld wird der Betreff automatisch „Anfrage = e($dept['name']) ?>“. + + Bezeichnung Betreff + Bezeichnung Beschreibung + + 'Ganze Zeile', 'half' => 'Halbe Zeile', 'third' => 'Ein Drittel', 'twothirds' => 'Zwei Drittel']; ?> + + Breite des Betreff-Feldes + $l): ?>>= e($l) ?> + Breite des Beschreibungs-Feldes + $l): ?>>= e($l) ?> + + Mit „Halbe Zeile“ steht der Betreff neben einem benachbarten eigenen Feld (zum Beispiel einem Zahlenfeld direkt vor oder nach dem Betreff). + > Beschreibung (großes Textfeld) abfragen + Ohne Beschreibung wird der Text des Tickets aus deinen eigenen Feldern zusammengesetzt („Bezeichnung: Eingabe“). + Platzhaltertext der Beschreibung + + > Datei-Anhang erlauben + + Formular speichern + + + + + + Felder dieser Abteilung + + ReihenfolgeAbschnittBezeichnungTypPflichtAktiv + + + + = (int) $f['sort_order'] ?> + + = e($f['label']) ?> + = e($fieldTypes[$f['field_type']] ?? $f['field_type']) ?> + = $f['is_required'] ? '✓' : '–' ?> + = $f['is_active'] ? '✓' : '–' ?> + + Bearbeiten · + + = csrf_field() ?> + Löschen + + + + + Noch keine eigenen Felder. Rechts kannst du das erste anlegen. + + + + + + + = $editField ? 'Feld bearbeiten' : 'Neues Feld' ?> + + = csrf_field() ?> + + + + Bezeichnung + + Feldtyp + + $lbl): ?> + >= e($lbl) ?> + + + + Optionen (eine pro Zeile) + = e($_POST['options'] ?? $editField['options'] ?? '') ?> + + + Wo im Formular erscheint das Feld? + + [$grp, $lbl]): + if ($grp !== $lastGroup) { if ($lastGroup !== '') echo ''; echo ''; $lastGroup = $grp; } ?> + >= e($lbl) ?> + '; ?> + >Zusätzliche Angaben (eigener Abschnitt danach) + >Eigener Abschnitt mit eigenem Namen … + + + + + trim((string) ($x['form_section'] ?? '')), $fields), fn($v) => $v !== '' && $v[0] !== '@')) as $sn): ?> + + Felder mit gleichem Namen landen im selben Abschnitt. + + + + Anordnung der Optionen + + >Untereinander + >Nebeneinander + + + + + Hinweistext (optional) + = e($__hPlain) ?> + + Verlinkter Teil des Textes + Link-Adresse + + Bei einer Checkbox steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als Pflichtfeld muss angehakt werden. + Breite im Formular + + + >Ganze Zeile + >Halbe Zeile (zwei Felder nebeneinander) + >Ein Drittel (drei Felder nebeneinander) + >Zwei Drittel (mit einem Drittel-Feld) + + Aufeinanderfolgende Felder im selben Abschnitt, die zusammen in eine Zeile passen, stehen nebeneinander. + Reihenfolge (kleinere Zahl zuerst) + + > Pflichtfeld + > Aktiv + + = $editField ? 'Speichern' : 'Feld anlegen' ?> + Abbrechen + + + + + + + diff --git a/DeskHub-Webseite/staff/departments.php b/DeskHub-Webseite/staff/departments.php index 822168d3..d802b0f1 100644 --- a/DeskHub-Webseite/staff/departments.php +++ b/DeskHub-Webseite/staff/departments.php @@ -106,6 +106,7 @@ require __DIR__ . '/_header.php'; Abteilung Mitglieder= $editDept ? ' (' . count($members) . ')' : '' ?> + Ticket-Formular= !empty($editDept['own_form']) ? ' ✓' : '' ?> @@ -224,6 +225,7 @@ require __DIR__ . '/_header.php'; = e($fmt($d['updated_at'] ?? null)) ?> Bearbeiten · + Formular= !empty($d['own_form']) ? ' ✓' : '' ?> · = csrf_field() ?> diff --git a/DeskHub-Webseite/staff/forgot_password.php b/DeskHub-Webseite/staff/forgot_password.php index 892f88c0..243d930d 100644 --- a/DeskHub-Webseite/staff/forgot_password.php +++ b/DeskHub-Webseite/staff/forgot_password.php @@ -20,7 +20,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist $errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.'; } else { - $stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1'); + $stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1'); $stmt->execute([$email]); $staff = $stmt->fetch(); @@ -42,6 +42,7 @@ $pageTitle = 'Passwort vergessen'; Passwort vergessen – Mitarbeiterbereich "> += site_icon_links() ?> @@ -59,7 +60,7 @@ $pageTitle = 'Passwort vergessen'; = csrf_field() ?> E-Mail-Adresse - + Link anfordern diff --git a/DeskHub-Webseite/staff/help_topics.php b/DeskHub-Webseite/staff/help_topics.php index dbc511d4..f1b59baf 100644 --- a/DeskHub-Webseite/staff/help_topics.php +++ b/DeskHub-Webseite/staff/help_topics.php @@ -53,7 +53,7 @@ if ($editTopic) { $cfStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE help_topic_id = ? ORDER BY sort_order, label'); $cfStmt->execute([$editTopic['id']]); $topicFields = $cfStmt->fetchAll(); - $globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll(); + $globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND department_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll(); } $sortMode = ($_GET['sort'] ?? 'alpha') === 'manual' ? 'manual' : 'alpha'; $topics = $pdo->query( diff --git a/DeskHub-Webseite/staff/import_osticket.php b/DeskHub-Webseite/staff/import_osticket.php index 9540cdbd..8de1e7c6 100644 --- a/DeskHub-Webseite/staff/import_osticket.php +++ b/DeskHub-Webseite/staff/import_osticket.php @@ -31,11 +31,47 @@ function import_json(array $data): never function import_ajax_guard(): void { header('Content-Type: application/json'); - if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) import_json(['ok' => false, 'error' => 'CSRF']); + if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) { + // Ist eine Anfrage größer als post_max_size, verwirft PHP alle Formulardaten: dann fehlt auch das Token + import_json(['ok' => false, 'error' => 'Die Sitzung ist abgelaufen oder der Upload-Teil war größer als der Server erlaubt (post_max_size ' . ini_get('post_max_size') . '). Bitte die Seite neu laden und erneut versuchen.']); + } } $stateDir = osticket_state_dir(); +/** PHP-Größenangabe ("2M", "128K", "1G") in Bytes. */ +function import_ini_bytes(string $v): int +{ + $v = trim($v); + if ($v === '' || $v === '-1') return PHP_INT_MAX; + $n = (float) $v; + return (int) match (strtolower(substr($v, -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n }; +} + +/** Größe eines Upload-Teils: so groß wie der Server erlaubt (höchstens 5 MB, mit Reserve), damit der Upload auch bei kleinen Limits (z. B. 2 MB) klappt. */ +function import_chunk_size(): int +{ + $limit = min(import_ini_bytes((string) ini_get('upload_max_filesize')), import_ini_bytes((string) ini_get('post_max_size'))); + $size = (int) ($limit * 0.8); + $size = intdiv($size, 65536) * 65536; + return max(131072, min(5 * 1048576, $size)); +} + +/** Verständliche Meldung zu einem PHP-Upload-Fehlercode. */ +function import_upload_error(?array $file): string +{ + $code = $file['error'] ?? UPLOAD_ERR_NO_FILE; + $limit = ini_get('upload_max_filesize') . ' / post_max_size ' . ini_get('post_max_size'); + return match ($code) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Der Server lehnt den Upload-Teil ab, er ist größer als erlaubt (upload_max_filesize ' . $limit . '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).', + UPLOAD_ERR_PARTIAL => 'Der Upload-Teil kam nur unvollständig an (Verbindung unterbrochen). Bitte erneut versuchen.', + UPLOAD_ERR_NO_FILE => 'Es kam kein Upload-Teil an.', + UPLOAD_ERR_NO_TMP_DIR => 'Auf dem Server fehlt der temporäre Ordner für Uploads (upload_tmp_dir). Bitte den Hoster fragen oder die Datei per FTP hochladen (Option 1).', + UPLOAD_ERR_CANT_WRITE => 'Der Server konnte den Upload-Teil nicht speichern (Speicherplatz oder Schreibrechte). Bitte die Datei per FTP hochladen (Option 1).', + default => 'Upload fehlgeschlagen (PHP-Fehlercode ' . $code . ').', + }; +} + // ── Chunked Upload ───────────────────────────────────────────────────────── if (($_POST['action'] ?? '') === 'upload_chunk') { import_ajax_guard(); @@ -43,10 +79,12 @@ if (($_POST['action'] ?? '') === 'upload_chunk') { $idx = (int) ($_POST['chunk_index'] ?? 0); $total = (int) ($_POST['total_chunks'] ?? 1); $tmpFile = $stateDir . '/upload.sql'; - if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => 'Chunk-Fehler']); - $fh = fopen($tmpFile, $idx === 0 ? 'wb' : 'ab'); - fwrite($fh, file_get_contents($chunk['tmp_name'])); + if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => import_upload_error($chunk)]); + $fh = @fopen($tmpFile, $idx === 0 ? 'wb' : 'ab'); + if (!$fh) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht gespeichert werden: Der Import-Ordner ist nicht beschreibbar (' . $stateDir . ').']); + $written = fwrite($fh, (string) file_get_contents($chunk['tmp_name'])); fclose($fh); + if ($written === false || $written < (int) filesize($chunk['tmp_name'])) import_json(['ok' => false, 'error' => 'Die Datei konnte nicht vollständig gespeichert werden (Speicherplatz voll?).']); $done = ($idx + 1) >= $total; if ($done) { $_SESSION['import_sql_path'] = $tmpFile; @@ -64,8 +102,11 @@ if (($_POST['action'] ?? '') === 'use_path') { $filename = basename(trim($_POST['sql_filename'] ?? '')); $fullPath = realpath(__DIR__ . '/../' . $filename); $rootDir = realpath(__DIR__ . '/..'); - if (!$filename || !$fullPath || strpos($fullPath, $rootDir) !== 0 || !file_exists($fullPath)) { - flash('error', 'Datei nicht gefunden: ' . $filename); redirect('/staff/import_osticket.php'); + if ($filename === '') { + flash('error', 'Bitte den Dateinamen der SQL-Datei eintragen (das graue Beispiel im Feld ist nur ein Platzhalter).'); redirect('/staff/import_osticket.php'); + } + if (!$fullPath || strpos($fullPath, $rootDir) !== 0 || !is_file($fullPath)) { + flash('error', 'Datei nicht gefunden: ' . $filename . '. Sie muss im DeskHub-Hauptordner auf dem Server liegen (dort, wo auch index.php liegt).'); redirect('/staff/import_osticket.php'); } $_SESSION['import_sql_path'] = $fullPath; $_SESSION['import_filename'] = $filename; @@ -170,7 +211,7 @@ require __DIR__ . '/_header.php'; Verwenden → - Option 2: Direkt hochladen (in 5-MB-Teilen) + Option 2: Direkt hochladen (in Teilen von = round(import_chunk_size() / 1048576, 1) ?> MB, passend zum Limit dieses Servers) @@ -187,7 +228,7 @@ require __DIR__ . '/_header.php'; var btn = document.getElementById('upload-btn'); btn.disabled = true; document.getElementById('upload-progress').style.display = 'block'; - var chunkSize = 5*1024*1024, totalChunks = Math.ceil(file.size/chunkSize), idx = 0; + var chunkSize = = import_chunk_size() ?>, totalChunks = Math.ceil(file.size/chunkSize), idx = 0; function next() { var blob = file.slice(idx*chunkSize, (idx+1)*chunkSize); var fd = new FormData(); @@ -195,12 +236,13 @@ require __DIR__ . '/_header.php'; fd.append('chunk',blob,file.name); fd.append('chunk_index',idx); fd.append('total_chunks',totalChunks); fd.append('filename',file.name); fetch('/staff/import_osticket.php',{method:'POST',body:fd}) - .then(r=>r.json()).then(d=>{ - if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;return;} + .then(r=>r.text()).then(function(t){ + var d; try { d = JSON.parse(t); } catch(e) { d = {ok:false,error:'Der Server hat den Upload-Teil nicht angenommen (vermutlich ist er größer als das Limit post_max_size ' + = json_encode((string) ini_get('post_max_size')) ?> + '). Laden Sie die Seite neu und versuchen Sie es erneut, oder legen Sie die Datei per FTP in den DeskHub-Ordner (Option 1).'}; } + if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;document.getElementById('upload-bar').style.background='#dc2626';return;} idx++; var pct=Math.round(idx/totalChunks*100); document.getElementById('upload-bar').style.width=pct+'%'; - document.getElementById('upload-status').textContent=pct+'% - '+Math.round(idx*5)+' MB'; + document.getElementById('upload-status').textContent=pct+'% - '+Math.round(Math.min(idx*chunkSize,file.size)/1048576)+' von '+Math.round(file.size/1048576)+' MB'; if(d.done){ document.getElementById('upload-bar').style.background='#22c55e'; setTimeout(()=>{window.location.href='/staff/import_osticket.php?step=analyze';},400); diff --git a/DeskHub-Webseite/staff/login.php b/DeskHub-Webseite/staff/login.php index 55490eb0..c9c01ecb 100644 --- a/DeskHub-Webseite/staff/login.php +++ b/DeskHub-Webseite/staff/login.php @@ -35,16 +35,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $pass = $_POST['password'] ?? ''; if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) { $errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.'; - } elseif ($email && !rate_limit_check('staff_login_email', $email, 5, 15)) { + } elseif ($email && !rate_limit_check('staff_login_email', strtolower($email), 5, 15)) { // Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs $errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.'; } else { - $stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1'); + $stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1'); $stmt->execute([$email]); $staff = $stmt->fetch(); if ($staff && password_verify($pass, $staff['password_hash'])) { // Erfolgreicher Login – Zähler zurücksetzen - rate_limit_reset('staff_login_email', $email); + rate_limit_reset('staff_login_email', strtolower($email)); if ($staff['totp_enabled']) { $_SESSION['totp_pending_staff_id'] = $staff['id']; $need2fa = true; @@ -79,7 +79,7 @@ $displayName = get_setting('display_name', config()['site']['name']); - += site_icon_links() ?> @@ -90,7 +90,9 @@ $displayName = get_setting('display_name', config()['site']['name']); - = e($displayName) ?> + + = e($displayName) ?> + ← Zum Kundenportal @@ -125,9 +127,9 @@ $displayName = get_setting('display_name', config()['site']['name']); = csrf_field() ?> E-Mail-Adresse - + Passwort - + Passwort vergessen? diff --git a/DeskHub-Webseite/staff/plugin.php b/DeskHub-Webseite/staff/plugin.php index b0eaa3c8..d85ce7db 100644 --- a/DeskHub-Webseite/staff/plugin.php +++ b/DeskHub-Webseite/staff/plugin.php @@ -20,8 +20,9 @@ $menu = reset($registered); if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); } $pageTitle = $menu['label']; -$activePage = 'plugin_' . $slug; +$activePage = !empty($menu['belongs_to']) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“) require __DIR__ . '/_header.php'; +if (!empty($menu['belongs_to'])) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page); plugin_guard($slug, function () use ($file, $staff, $pdo) { // Variablen für die Plugin-Seite: $staff, $pdo include $file; diff --git a/DeskHub-Webseite/staff/profile.php b/DeskHub-Webseite/staff/profile.php index 8d672a5f..c0e5570b 100644 --- a/DeskHub-Webseite/staff/profile.php +++ b/DeskHub-Webseite/staff/profile.php @@ -22,7 +22,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // E-Mail-Duplikat prüfen (andere Mitarbeiter) if (!$errors) { - $dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE email = ? AND id != ?'); + $dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id != ?'); $dup->execute([$email, $staff['id']]); if ((int)$dup->fetchColumn()) $errors[] = 'Diese E-Mail-Adresse wird bereits von einem anderen Mitarbeiter verwendet.'; } @@ -372,20 +372,11 @@ require __DIR__ . '/_header.php'; = csrf_field() ?> - - - B - I - U - • - 🔗 - - = $me['signature'] ?? '' ?> - - + = e($me['signature'] ?? '') ?> + Bilder einfügen mit 🖼️. Zum Verkleinern oder Vergrößern das Bild anklicken: Mit dem runden Anfasser ziehen oder 25 / 50 / 75 % / Original wählen. - Speichern - Löschen + Speichern + Löschen @@ -399,12 +390,11 @@ require __DIR__ . '/_header.php'; diff --git a/DeskHub-Webseite/staff/reset_password.php b/DeskHub-Webseite/staff/reset_password.php index 82533e41..bf6a5b73 100644 --- a/DeskHub-Webseite/staff/reset_password.php +++ b/DeskHub-Webseite/staff/reset_password.php @@ -23,7 +23,7 @@ if (!$token || !$email) { $pdo->prepare('UPDATE staff SET password_hash = ? WHERE email = ?') ->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]); consume_password_reset('staff', $token); - $staffRow = $pdo->prepare('SELECT id FROM staff WHERE email = ?'); + $staffRow = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?)'); $staffRow->execute([$email]); audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt'); $done = true; @@ -39,6 +39,7 @@ $pageTitle = 'Passwort zurücksetzen'; Passwort zurücksetzen – Mitarbeiterbereich "> += site_icon_links() ?> diff --git a/DeskHub-Webseite/staff/settings.php b/DeskHub-Webseite/staff/settings.php index ea89348d..af2e185e 100644 --- a/DeskHub-Webseite/staff/settings.php +++ b/DeskHub-Webseite/staff/settings.php @@ -174,6 +174,33 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); } } } + if ($action === 'upload_favicon') { + $file = $_FILES['favicon'] ?? []; + $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION)); + $err = null; + if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.'; + elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.'); + elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.'; + elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.'; + else $err = favicon_validate_file($file['tmp_name'], $ext); + if ($err !== null) { $errors[] = $err; } + else { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; } + else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); } + } + } + if ($action === 'remove_favicon') { + foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2); + set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design'); + } + if ($action === 'save_logo_options') { + $hide = !empty($_POST['logo_hide_name']) ? '1' : '0'; + set_setting('logo_hide_name', $hide); + audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt'); + flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.'); + redirect('/staff/settings.php?tab=design'); + } if ($action === 'remove_logo') { foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2); set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design'); @@ -556,7 +583,7 @@ require __DIR__ . '/_header.php'; Firmenlogo - PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. + PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden. @@ -565,6 +592,14 @@ require __DIR__ . '/_header.php'; = csrf_field() ?> Entfernen + + = csrf_field() ?> + + > + Name „= e(get_setting('display_name', 'DeskHub')) ?>“ neben dem Logo ausblenden + + Speichern + Kein Logo hinterlegt. @@ -575,6 +610,30 @@ require __DIR__ . '/_header.php'; + + Favicon (Symbol im Browser-Tab) + ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo. + + + + + + + + = csrf_field() ?> + Entfernen + + + + Kein eigenes Favicon hinterlegt. + + + = csrf_field() ?> + + Hochladen + + + Titelbild (Hero-Banner) Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB. diff --git a/DeskHub-Webseite/staff/ticket_create.php b/DeskHub-Webseite/staff/ticket_create.php index a3901732..2c1530ff 100644 --- a/DeskHub-Webseite/staff/ticket_create.php +++ b/DeskHub-Webseite/staff/ticket_create.php @@ -20,7 +20,7 @@ $departments = $pdo->query('SELECT * FROM departments WHERE is_active = 1 ORDER $priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); $helpTopics = help_topic_options($pdo); $allAgents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); -$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll(); +$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll(); // Abteilungen auf zugängliche einschränken if ($myDeptIds !== null) { @@ -285,6 +285,13 @@ require __DIR__ . '/_header.php'; >= e($opt) ?> + + + + = $posted === $opt ? 'checked' : '' ?>> + = e($opt) ?> + + > diff --git a/DeskHub-Webseite/staff/tickets.php b/DeskHub-Webseite/staff/tickets.php index 16c54ce5..72a1c865 100644 --- a/DeskHub-Webseite/staff/tickets.php +++ b/DeskHub-Webseite/staff/tickets.php @@ -61,7 +61,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' } $now = db_now(); + // Abteilung wechseln: Ziel prüfen (muss existieren und für den Mitarbeiter erreichbar sein), Rechte je Ticket später + $moveTarget = null; + $movedCount = 0; + $movedSkipped = ['same' => 0, 'perm' => 0]; + if ($bulkAction === 'department') { + $newDeptId = (int) ($_POST['bulk_department_id'] ?? 0); + $targetStmt = $pdo->prepare('SELECT id, name FROM departments WHERE id = ?'); + $targetStmt->execute([$newDeptId]); + $moveTarget = $targetStmt->fetch() ?: null; + if (!$moveTarget) { + $bulkErrors[] = 'Bitte eine Ziel-Abteilung wählen.'; + $allowedTickets = []; + } elseif ($myDeptIds !== null && !in_array($newDeptId, $myDeptIds, true)) { + $bulkErrors[] = 'Sie haben keinen Zugriff auf die Abteilung „' . $moveTarget['name'] . '“.'; + $allowedTickets = []; + } + } foreach ($allowedTickets as $t) { + if ($bulkAction === 'department' && $moveTarget) { + if ((int) $t['department_id'] === (int) $moveTarget['id']) { $movedSkipped['same']++; continue; } + if (!has_permission($staff, 'transfer_ticket', (int) $t['department_id'])) { $movedSkipped['perm']++; continue; } + // SLA-Fristen nach den Regeln der neuen Abteilung neu berechnen (wie beim Ändern in der Ticketansicht) + $prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); + $prioStmt->execute([$t['priority_id']]); + $prioRow = $prioStmt->fetch(); + $sla = $prioRow ? calc_sla_dates($prioRow, $t['created_at'], (int) $moveTarget['id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null; + $pdo->prepare('UPDATE tickets SET department_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?') + ->execute([(int) $moveTarget['id'], $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]); + $oldDeptName = ''; + foreach ($departments as $d0) { if ((int) $d0['id'] === (int) $t['department_id']) $oldDeptName = $d0['name']; } + log_activity($pdo, $t['id'], $staff['id'], 'Abteilung geändert (Sammelaktion): ' . $oldDeptName . ' → ' . $moveTarget['name']); + $movedCount++; + continue; + } if ($bulkAction === 'assign') { $newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null; $pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]); @@ -96,7 +129,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' } } - if (!$bulkErrors) { + if (!$bulkErrors && $bulkAction === 'department') { + $msg = $movedCount . ' Ticket(s) in die Abteilung „' . $moveTarget['name'] . '“ verschoben.'; + if ($movedSkipped['same']) $msg .= ' ' . $movedSkipped['same'] . ' waren schon dort.'; + if ($movedSkipped['perm']) $msg .= ' ' . $movedSkipped['perm'] . ' übersprungen (keine Berechtigung zum Verschieben).'; + flash($movedCount > 0 ? 'success' : 'error', $msg); + } elseif (!$bulkErrors) { $verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert'; flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.'); } else { @@ -542,6 +580,14 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st Priorität setzen + + + – Abteilung wählen – + = e($d['name']) ?> + + Abteilung ändern + + 'Offen','pending'=>'Wartet auf Kunde','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
Bei einer Checkbox steht der Text neben dem Kästchen, bei anderen Feldern unter dem Feld. Das Wort aus „Verlinkter Teil“ wird im Text zum Link auf die Adresse. Eine Checkbox als Pflichtfeld muss angehakt werden.
← Zurück zur Abteilung
Hier legst du fest, wie das Formular „Neues Ticket“ aussieht, wenn ein Kunde diese Abteilung wählt (direkt oder über ein Hilfe-Thema dieser Abteilung).
+ Ist das aktiv, erscheinen für diese Abteilung nur die unten festgelegten Felder. Die allgemeinen Ticket-Felder und die Felder der Hilfe-Themen werden dann nicht verwendet. + Name und E-Mail-Adresse werden immer abgefragt, damit wir antworten können. +
Ohne Betrefffeld wird der Betreff automatisch „Anfrage = e($dept['name']) ?>“.
Mit „Halbe Zeile“ steht der Betreff neben einem benachbarten eigenen Feld (zum Beispiel einem Zahlenfeld direkt vor oder nach dem Betreff).
Ohne Beschreibung wird der Text des Tickets aus deinen eigenen Feldern zusammengesetzt („Bezeichnung: Eingabe“).
Felder mit gleichem Namen landen im selben Abschnitt.
Aufeinanderfolgende Felder im selben Abschnitt, die zusammen in eine Zeile passen, stehen nebeneinander.
Option 2: Direkt hochladen (in 5-MB-Teilen)
Option 2: Direkt hochladen (in Teilen von = round(import_chunk_size() / 1048576, 1) ?> MB, passend zum Limit dieses Servers)
Bilder einfügen mit 🖼️. Zum Verkleinern oder Vergrößern das Bild anklicken: Mit dem runden Anfasser ziehen oder 25 / 50 / 75 % / Original wählen.
PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt.
PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.
Kein Logo hinterlegt.
ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.
Kein eigenes Favicon hinterlegt.
Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.