Upload via GUI (267 Dateien)

This commit is contained in:
2026-10-19 18:05:53 +00:00
parent 42554bdadf
commit 1b29349219
7 changed files with 1570 additions and 1341 deletions
+958 -906
View File
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,16 @@ if ($departmentIds !== null) {
}
}
// Optional: nur Textbausteine für die Abteilung eines Tickets (diese Abteilung plus die für alle Abteilungen)
$onlyDept = isset($_GET['departmentId']) && ctype_digit((string) $_GET['departmentId']) ? (int) $_GET['departmentId'] : null;
if ($onlyDept !== null) {
if ($departmentIds !== null && !in_array($onlyDept, $departmentIds, true)) {
api_error(403, 'FORBIDDEN', 'Kein Zugriff auf diese Abteilung.');
}
$deptFilter = ' AND (cr.department_id IS NULL OR cr.department_id = ?)';
$deptParams = [$onlyDept];
}
$stmt = $pdo->prepare(
"SELECT cr.id, cr.title, cr.body, cr.department_id
FROM canned_responses cr
+30 -12
View File
@@ -66,14 +66,20 @@ function api_require_https(): void
}
}
/**
* Liest die Anmeldung der Apps. Viele Hoster (Apache mit PHP-FPM/FastCGI, z. B. Plesk) reichen den Header "Authorization" nicht an PHP weiter.
* Deshalb senden die Apps denselben Wert zusätzlich als "X-DeskHub-Authorization", der überall ankommt; hier gilt der erste vorhandene.
*/
function api_authorization_header(): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if ($header === '' && function_exists('getallheaders')) {
$headers = getallheaders();
$header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (trim((string) $header) === '') $header = $_SERVER['HTTP_X_DESKHUB_AUTHORIZATION'] ?? '';
if (trim((string) $header) === '' && function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (in_array(strtolower((string) $name), ['authorization', 'x-deskhub-authorization'], true) && trim((string) $value) !== '') { $header = $value; break; }
}
}
return trim($header);
return trim((string) $header);
}
function api_bearer_token(): ?string
@@ -204,11 +210,27 @@ function api_find_ticket(PDO $pdo, array $staff, string $number): array
return $ticket;
}
/** Adresse des ersten Bildes in einem HTML-Text (absolut, nur http/https); leer, wenn keines vorhanden ist. */
function api_first_image_url(?string $html): string
{
if (!preg_match('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1/is', (string) $html, $m)) return '';
$src = trim(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (str_starts_with($src, '//')) $src = 'https:' . $src;
elseif (str_starts_with($src, '/')) $src = base_url() . $src;
return preg_match('#^https?://#i', $src) ? $src : '';
}
function api_plain_text(?string $html): string
{
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
// Weitere Blöcke (div, Listen, Tabellenzeilen, Überschriften …) enden ebenfalls mit einem Zeilenumbruch, Listenpunkte bekommen einen Punkt
$text = preg_replace('/<li\b[^>]*>/i', '• ', $text);
$text = preg_replace('/<\/(div|li|tr|h[1-6]|blockquote|pre|ul|ol|table)\s*>/i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/[ \t]+\n/', "\n", $text); // Leerzeichen am Zeilenende
$text = preg_replace('/\n{3,}/', "\n\n", $text); // höchstens eine Leerzeile
return trim($text);
}
function api_ticket_summary(array $ticket): array
@@ -247,7 +269,7 @@ function api_ticket_summary(array $ticket): array
];
}
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus): int
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int
{
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
api_require_permission($staff, $permission, (int) $ticket['department_id']);
@@ -331,12 +353,8 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
}
if (!$isNote) {
$signature = trim((string) ($staff['signature'] ?? ''));
if ($signature === '') {
$signatureStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$signatureStmt->execute([$ticket['department_id']]);
$signature = (string) $signatureStmt->fetchColumn();
}
// Signatur: optional vom Aufrufer gewählt (own, dept, none), sonst eigene, ersatzweise die der Abteilung
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $signatureChoice);
$collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
$collabStmt->execute([$ticket['id']]);
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
+44 -44
View File
@@ -1,44 +1,44 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
api_require_https();
$body = api_request_body();
$email = mb_strtolower(trim((string) ($body['email'] ?? '')));
$password = (string) ($body['password'] ?? '');
$totpCode = trim((string) ($body['totpCode'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
}
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|| !rate_limit_check('mobile_staff_login_email', $email, 5, 15)) {
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
}
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
$stmt->execute([$email]);
$staff = $stmt->fetch();
if (!$staff || !password_verify($password, $staff['password_hash'])) {
api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
}
if (!empty($staff['totp_enabled'])) {
if ($totpCode === '') {
api_json(['requiresTotp' => true], 202);
}
if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
}
}
rate_limit_reset('mobile_staff_login_email', $email);
rate_limit_reset('mobile_staff_login_ip', client_ip());
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
api_json([
'token' => $issued['token'],
'expiresAt' => $issued['expires_at'],
'staff' => api_staff_payload($staff),
]);
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
api_require_https();
$body = api_request_body();
$email = mb_strtolower(trim((string) ($body['email'] ?? '')));
$password = (string) ($body['password'] ?? '');
$totpCode = trim((string) ($body['totpCode'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
}
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|| !rate_limit_check('mobile_staff_login_email', strtolower($email), 5, 15)) {
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
}
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
$stmt->execute([$email]);
$staff = $stmt->fetch();
if (!$staff || !password_verify($password, $staff['password_hash'])) {
api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
}
if (!empty($staff['totp_enabled'])) {
if ($totpCode === '') {
api_json(['requiresTotp' => true], 202);
}
if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
}
}
rate_limit_reset('mobile_staff_login_email', strtolower($email));
rate_limit_reset('mobile_staff_login_ip', client_ip());
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
api_json([
'token' => $issued['token'],
'expiresAt' => $issued['expires_at'],
'staff' => api_staff_payload($staff),
]);
+1 -1
View File
@@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$errors) {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$stmt->execute([$email]);
if ($stmt->fetch()) {
$errors[] = 'Für diese E-Mail-Adresse existiert bereits ein Konto.';
+526 -378
View File
@@ -1,378 +1,526 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
$user = current_user();
$pdo = get_db();
$departments = departments_for_select($pdo);
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$helpTopics = help_topic_options($pdo, true);
$useHelpTopics = count($helpTopics) > 0;
// Ohne Hilfe-Themen gibt es keine Themenauswahl auf dem Formular, daher nur themenunabhängige Felder anzeigen.
$customFields = $useHelpTopics
? $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll()
: $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND help_topic_id IS NULL ORDER BY sort_order, label')->fetchAll();
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
// Honeypot: für Menschen unsichtbares Feld, das leer bleiben muss. Bots füllen
// häufig alle Formularfelder aus, daher dient dies als einfache Spam-Bremse.
if (!empty($_POST['website'])) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
if (!$user && !rate_limit_check('ticket_create', client_ip(), 8, 60)) {
$errors[] = 'Sie haben in kurzer Zeit zu viele Tickets erstellt. Bitte versuchen Sie es später erneut.';
}
$name = trim($_POST['name'] ?? ($user['name'] ?? ''));
$email = trim($_POST['email'] ?? ($user['email'] ?? ''));
$subject = trim($_POST['subject'] ?? '');
$message = trim($_POST['message'] ?? '');
// Gesperrte Absender: Ticket wird wie beim Honeypot kommentarlos abgewiesen,
// damit nicht erkennbar ist, dass die Adresse gezielt blockiert wurde.
if ($email !== '' && is_banned($email)) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
$helpTopicId = null;
$deptId = null;
$prioId = null;
if ($useHelpTopics) {
$helpTopicId = (int) ($_POST['help_topic_id'] ?? 0);
$topic = null;
foreach ($helpTopics as $t) {
if ($t['id'] == $helpTopicId) { $topic = $t; break; }
}
if (!$topic) {
$errors[] = 'Bitte wählen Sie ein Anliegen aus.';
} else {
$deptId = (int) $topic['department_id'];
$prioId = (int) $topic['priority_id'];
}
} else {
$deptId = (int) ($_POST['department_id'] ?? 0);
$prioId = (int) ($_POST['priority_id'] ?? 0);
if (!in_array($deptId, array_column($departments, 'id'))) $errors[] = 'Bitte wählen Sie eine Abteilung.';
if (!in_array($prioId, array_column($priorities, 'id'))) $errors[] = 'Bitte wählen Sie eine Priorität.';
}
if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
if ($subject === '') $errors[] = 'Bitte geben Sie einen Betreff an.';
if ($message === '') $errors[] = 'Bitte beschreiben Sie Ihr Anliegen.';
// Individuelle Felder validieren (nur Felder, die zum gewählten Hilfe-Thema gehören oder themenunabhängig sind)
$customValues = []; // field id => submitted value
foreach ($customFields as $f) {
if ($f['help_topic_id'] !== null && (int) $f['help_topic_id'] !== (int) $helpTopicId) continue;
$fieldName = 'custom_' . $f['id'];
$val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fieldName]) ? '1' : '0') : trim($_POST[$fieldName] ?? '');
if ($f['is_required'] && $f['field_type'] !== 'checkbox' && $val === '') {
$errors[] = 'Bitte füllen Sie das Feld „' . $f['label'] . '“ aus.';
}
$customValues[$f['id']] = $val;
}
// Datei-Upload (optional, ein Anhang)
$uploadInfo = null;
if (!$errors && !empty($_FILES['attachment']['name'])) {
$uploadInfo = handle_ticket_upload($_FILES['attachment']);
if (isset($uploadInfo['error'])) {
$errors[] = $uploadInfo['error'];
}
}
if (!$errors) {
// Ticket-Regeln auswerten: koennen Abteilung/Prioritaet/Hilfe-Thema vor dem Anlegen
// ueberschreiben, das Ticket abweisen, oder nach dem Anlegen einen Mitarbeiter zuweisen/Tags setzen.
$ruleResult = evaluate_ticket_rules($pdo, [
'subject' => $subject,
'message' => $message,
'requester_email' => $email,
'requester_name' => $name,
'source' => 'web',
]);
if ($ruleResult['reject']) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $helpTopicId = $ruleResult['set_help_topic'];
$priority = null;
foreach ($priorities as $p) {
if ($p['id'] == $prioId) { $priority = $p; break; }
}
$sla = calc_sla_dates($priority, null, (int) $deptId, !empty($helpTopicId) ? (int) $helpTopicId : null);
$stmt = $pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
$ticketNumber = generate_ticket_number($pdo);
$now = db_now();
// IP kürzen wenn Anonymisierung aktiv
$clientIp = client_ip();
if (get_setting('ip_anonymize', '0') === '1') {
$clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp);
$clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp);
}
$stmt->execute([
$ticketNumber, $subject, $user['id'] ?? null, $name, $email, $deptId,
$helpTopicId, $prioId, 'open', 'web', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
$stmt = $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at)
VALUES (?,?,?,?,0,?)'
);
$stmt->execute([$ticketId, 'customer', $user['id'] ?? null, $message, $now]);
$threadId = (int) $pdo->lastInsertId();
if ($uploadInfo) {
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)'
);
$stmt->execute([$threadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]);
}
if ($customValues) {
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
foreach ($customValues as $fieldId => $val) {
if ($val === '' ) continue;
$cfStmt->execute([$ticketId, $fieldId, $val]);
}
}
log_activity($pdo, $ticketId, null, 'Ticket erstellt');
// Verbleibende Regel-Aktionen, die eine vorhandene Ticket-ID benötigen
$assignTo = $ruleResult['assign_staff'];
if ($assignTo === null) {
// Auto-Zuweisung: war der Kunde schon mal einem Mitarbeiter zugewiesen?
$prevStmt = $pdo->prepare(
"SELECT assigned_staff_id FROM tickets
WHERE LOWER(requester_email) = LOWER(?)
AND assigned_staff_id IS NOT NULL
AND status NOT IN ('closed','resolved')
AND id != ?
ORDER BY updated_at DESC LIMIT 1"
);
$prevStmt->execute([$email, $ticketId]);
$prevId = $prevStmt->fetchColumn();
if ($prevId) {
$assignTo = (int) $prevId;
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (letzter Bearbeiter)');
}
} else {
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Ticket-Regel)');
}
if ($assignTo !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) {
apply_tag_to_ticket($pdo, $ticketId, $tagName);
}
if ($ruleResult['matched_rules']) {
log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules']));
}
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$deptStmt->execute([$deptId]);
$dept = $deptStmt->fetch();
$ticketForNotify = [
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $name,
'requester_email' => $email,
'department_id' => $deptId,
'department_name' => $dept['name'] ?? null,
];
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
notify_staff_new_ticket($ticketForNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
if (file_exists(__DIR__ . '/includes/push.php')) {
require_once __DIR__ . '/includes/push.php';
push_new_ticket($ticketForNotify);
}
notify_customer_ticket_created($ticketForNotify);
$_SESSION['guest_ticket_access'][$ticketNumber] = true;
flash('success', "Ihr Ticket #{$ticketNumber} wurde erstellt. Wir melden uns so schnell wie möglich.");
redirect('/ticket_view.php?n=' . urlencode($ticketNumber));
}
}
$pageTitle = 'Neues Ticket';
$activePage = 'new';
require __DIR__ . '/includes/header_customer.php';
?>
<div class="ticket-create-page">
<div class="topbar ticket-create-header">
<div>
<h1>Neues Ticket erstellen</h1>
<p class="muted" style="margin:4px 0 0">Beschreiben Sie Ihr Anliegen so genau wie möglich – das hilft uns, schneller zu helfen.</p>
</div>
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
</div>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<div class="card ticket-create-card">
<form method="post" enctype="multipart/form-data" class="ticket-create-form">
<?= csrf_field() ?>
<!-- Honeypot -->
<div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<?php if (!$user): ?>
<!-- Schritt 1: Kontaktdaten -->
<div class="ticket-form-section">
<p class="ticket-section-label"><span class="ticket-section-number">1</span>Ihre Kontaktdaten</p>
<div class="field-row">
<div>
<label for="name">Name <span style="color:var(--danger)">*</span></label>
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" placeholder="Max Mustermann" required autofocus>
</div>
<div>
<label for="email">E-Mail-Adresse <span style="color:var(--danger)">*</span></label>
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="max@beispiel.de" required>
</div>
</div>
<p class="ticket-form-hint">
<a href="/register.php">Kostenloses Konto erstellen</a> um alle Ihre Tickets im Überblick zu behalten.
</p>
</div>
<?php endif; ?>
<!-- Schritt 2: Anliegen -->
<div class="ticket-form-section">
<p class="ticket-section-label"><span class="ticket-section-number">2</span>Ihr Anliegen</p>
<?php if ($useHelpTopics): ?>
<label for="help_topic_id">Kategorie <span style="color:var(--danger)">*</span></label>
<select id="help_topic_id" name="help_topic_id" required onchange="updateTopicFieldVisibility()" style="margin-bottom:14px">
<option value="">– bitte wählen –</option>
<?php foreach ($helpTopics as $t): ?>
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<div class="field-row" style="margin-bottom:14px">
<div>
<label for="department_id">Abteilung <span style="color:var(--danger)">*</span></label>
<select id="department_id" name="department_id" required>
<option value="">– bitte wählen –</option>
<?php foreach ($departments as $d): ?>
<option value="<?= $d['id'] ?>" <?= (($_POST['department_id'] ?? '') == $d['id']) ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="priority_id">Priorität <span style="color:var(--danger)">*</span></label>
<select id="priority_id" name="priority_id" required>
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= (($_POST['priority_id'] ?? '') == $p['id'] || (!isset($_POST['priority_id']) && $p['name']==='Normal')) ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php endif; ?>
<label for="subject">Betreff <span style="color:var(--danger)">*</span></label>
<input type="text" id="subject" name="subject" value="<?= e($_POST['subject'] ?? '') ?>" placeholder="Kurze Zusammenfassung Ihres Problems" required style="margin-bottom:14px">
<label for="message">Beschreibung <span style="color:var(--danger)">*</span></label>
<textarea id="message" name="message" required placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…" style="min-height:140px"><?= e($_POST['message'] ?? '') ?></textarea>
</div>
<?php if ($customFields): ?>
<div class="ticket-form-section">
<p class="ticket-section-label"><span class="ticket-section-number">3</span>Zusätzliche Angaben</p>
<?php foreach ($customFields as $f):
$fieldName = 'custom_' . $f['id'];
$posted = $_POST[$fieldName] ?? '';
?>
<div class="custom-field-group" data-topic="<?= $f['help_topic_id'] !== null ? (int) $f['help_topic_id'] : '' ?>">
<label for="<?= $fieldName ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' <span style="color:var(--danger)">*</span>' : '' ?></label>
<?php if ($f['field_type'] === 'textarea'): ?>
<textarea id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
<?php elseif ($f['field_type'] === 'select'): ?>
<select id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<option value="">– bitte wählen –</option>
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $opt): $opt = trim($opt); if ($opt === '') continue; ?>
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($f['field_type'] === 'checkbox'): ?>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:4px">
<input type="checkbox" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="1" style="width:auto" <?= !empty($posted) ? 'checked' : '' ?>>
<span><?= e($f['label']) ?></span>
</label>
<?php elseif ($f['field_type'] === 'number'): ?>
<input type="number" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php elseif ($f['field_type'] === 'date'): ?>
<input type="date" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php else: ?>
<input type="text" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Anhang -->
<div class="ticket-form-section ticket-attachment-section">
<p class="ticket-section-label"><span class="ticket-section-number"><?= $customFields ? '4' : '3' ?></span>Anhang <span class="ticket-section-optional">(optional)</span></p>
<label for="attachment" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1.5px dashed var(--border);border-radius:var(--radius);cursor:pointer;font-size:.85rem;color:var(--muted);font-weight:400;transition:border-color .15s" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span id="att-label">Datei auswählen</span>
</label>
<input type="file" id="attachment" name="attachment" style="display:none" onchange="document.getElementById('att-label').textContent=this.files[0]?this.files[0].name:'Datei auswählen'">
</div>
<div class="ticket-form-actions">
<p class="ticket-form-hint">Pflichtfelder sind mit <span class="ticket-required">*</span> gekennzeichnet.</p>
<button type="submit" class="btn ticket-submit">Ticket erstellen</button>
</div>
</form>
</div>
</div>
<?php if ($useHelpTopics): ?>
<script>
function updateTopicFieldVisibility() {
var select = document.getElementById('help_topic_id');
var selectedTopic = select ? select.value : '';
document.querySelectorAll('.custom-field-group').forEach(function (group) {
var groupTopic = group.getAttribute('data-topic');
var matches = groupTopic === '' || groupTopic === selectedTopic;
group.style.display = matches ? '' : 'none';
group.querySelectorAll('input, select, textarea').forEach(function (el) {
if (!matches) {
if (el.required) { el.dataset.wasRequired = '1'; el.required = false; }
} else if (el.dataset.wasRequired === '1') {
el.required = true;
}
});
});
}
document.addEventListener('DOMContentLoaded', updateTopicFieldVisibility);
</script>
<?php endif; ?>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
<?php
require_once __DIR__ . '/includes/bootstrap.php';
$user = current_user();
$pdo = get_db();
$departments = departments_for_select($pdo);
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$helpTopics = help_topic_options($pdo, true);
$useHelpTopics = count($helpTopics) > 0;
// Ohne Hilfe-Themen gibt es keine Themenauswahl auf dem Formular, daher nur themenunabhängige Felder anzeigen.
$customFields = $useHelpTopics
? $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll()
: $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL AND help_topic_id IS NULL ORDER BY sort_order, label')->fetchAll();
// Abteilungen mit eigenem Formular: Texte und Felder (erscheinen nur, wenn der Kunde diese Abteilung wählt)
$deptForms = [];
foreach ($departments as $d) {
$df = department_form($pdo, (int) $d['id']);
if ($df) {
$deptForms[(int) $d['id']] = $df;
}
}
$topicDept = [];
foreach ($helpTopics as $t) $topicDept[(int) $t['id']] = (int) $t['department_id'];
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
// Honeypot: für Menschen unsichtbares Feld, das leer bleiben muss. Bots füllen
// häufig alle Formularfelder aus, daher dient dies als einfache Spam-Bremse.
if (!empty($_POST['website'])) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
if (!$user && !rate_limit_check('ticket_create', client_ip(), 8, 60)) {
$errors[] = 'Sie haben in kurzer Zeit zu viele Tickets erstellt. Bitte versuchen Sie es später erneut.';
}
$name = trim($_POST['name'] ?? ($user['name'] ?? ''));
$email = trim($_POST['email'] ?? ($user['email'] ?? ''));
$subject = trim($_POST['subject'] ?? '');
$message = trim($_POST['message'] ?? '');
// Gesperrte Absender: Ticket wird wie beim Honeypot kommentarlos abgewiesen,
// damit nicht erkennbar ist, dass die Adresse gezielt blockiert wurde.
if ($email !== '' && is_banned($email)) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
$helpTopicId = null;
$deptId = null;
$prioId = null;
if ($useHelpTopics) {
$helpTopicId = (int) ($_POST['help_topic_id'] ?? 0);
$topic = null;
foreach ($helpTopics as $t) {
if ($t['id'] == $helpTopicId) { $topic = $t; break; }
}
if (!$topic) {
$errors[] = 'Bitte wählen Sie ein Anliegen aus.';
} else {
$deptId = (int) $topic['department_id'];
$prioId = (int) $topic['priority_id'];
}
} else {
$deptId = (int) ($_POST['department_id'] ?? 0);
$prioId = (int) ($_POST['priority_id'] ?? 0);
if (!in_array($deptId, array_column($departments, 'id'))) $errors[] = 'Bitte wählen Sie eine Abteilung.';
if (!in_array($prioId, array_column($priorities, 'id'))) $errors[] = 'Bitte wählen Sie eine Priorität.';
}
// Eigenes Formular der gewählten Abteilung: ersetzt Betreff-/Feld-/Anhang-Vorgaben des Standardformulars
$ownForm = $deptId ? ($deptForms[(int) $deptId] ?? null) : null;
if ($ownForm && !$ownForm['subject_show']) $subject = 'Anfrage ' . $ownForm['department_name'];
if ($ownForm && !$ownForm['message_show']) {
// Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder
$lines = [];
foreach ($ownForm['fields'] as $ff) {
$raw = $ff['field_type'] === 'checkbox' ? (!empty($_POST['custom_' . $ff['id']]) ? 'Ja' : 'Nein') : trim((string) ($_POST['custom_' . $ff['id']] ?? ''));
if ($raw !== '') $lines[] = $ff['label'] . ': ' . $raw;
}
$message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben';
}
if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
if ($subject === '') $errors[] = 'Bitte geben Sie einen Betreff an.';
if ($message === '') $errors[] = 'Bitte beschreiben Sie Ihr Anliegen.';
// Individuelle Felder validieren (nur Felder, die zum gewählten Hilfe-Thema gehören oder themenunabhängig sind)
$customValues = []; // field id => submitted value
foreach ($ownForm ? $ownForm['fields'] : $customFields as $f) {
if (!$ownForm && ($f['department_id'] !== null || ($f['help_topic_id'] !== null && (int) $f['help_topic_id'] !== (int) $helpTopicId))) continue;
$fieldName = 'custom_' . $f['id'];
$val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fieldName]) ? '1' : '0') : trim($_POST[$fieldName] ?? '');
if ($f['is_required'] && $f['field_type'] !== 'checkbox' && $val === '') {
$errors[] = 'Bitte füllen Sie das Feld „' . $f['label'] . '“ aus.';
}
if ($f['is_required'] && $f['field_type'] === 'checkbox' && $val !== '1') {
$errors[] = 'Bitte bestätigen Sie „' . $f['label'] . '“.';
}
if (in_array($f['field_type'], ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options'])), true)) {
$errors[] = 'Bitte wählen Sie bei „' . $f['label'] . '“ eine der angebotenen Möglichkeiten.';
$val = '';
}
$customValues[$f['id']] = $val;
}
// Datei-Upload (optional, ein Anhang)
$uploadInfo = null;
if (!$errors && (!$ownForm || $ownForm['attachment_show']) && !empty($_FILES['attachment']['name'])) {
$uploadInfo = handle_ticket_upload($_FILES['attachment']);
if (isset($uploadInfo['error'])) {
$errors[] = $uploadInfo['error'];
}
}
if (!$errors) {
// Ticket-Regeln auswerten: koennen Abteilung/Prioritaet/Hilfe-Thema vor dem Anlegen
// ueberschreiben, das Ticket abweisen, oder nach dem Anlegen einen Mitarbeiter zuweisen/Tags setzen.
$ruleResult = evaluate_ticket_rules($pdo, [
'subject' => $subject,
'message' => $message,
'requester_email' => $email,
'requester_name' => $name,
'source' => 'web',
]);
if ($ruleResult['reject']) {
flash('error', 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.');
redirect('/ticket_new.php');
}
if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $helpTopicId = $ruleResult['set_help_topic'];
$priority = null;
foreach ($priorities as $p) {
if ($p['id'] == $prioId) { $priority = $p; break; }
}
$sla = calc_sla_dates($priority, null, (int) $deptId, !empty($helpTopicId) ? (int) $helpTopicId : null);
$stmt = $pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
$ticketNumber = generate_ticket_number($pdo);
$now = db_now();
// IP kürzen wenn Anonymisierung aktiv
$clientIp = client_ip();
if (get_setting('ip_anonymize', '0') === '1') {
$clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp);
$clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp);
}
$stmt->execute([
$ticketNumber, $subject, $user['id'] ?? null, $name, $email, $deptId,
$helpTopicId, $prioId, 'open', 'web', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
$stmt = $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at)
VALUES (?,?,?,?,0,?)'
);
$stmt->execute([$ticketId, 'customer', $user['id'] ?? null, $message, $now]);
$threadId = (int) $pdo->lastInsertId();
if ($uploadInfo) {
$stmt = $pdo->prepare(
'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)'
);
$stmt->execute([$threadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]);
}
if ($customValues) {
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
foreach ($customValues as $fieldId => $val) {
if ($val === '' ) continue;
$cfStmt->execute([$ticketId, $fieldId, $val]);
}
}
log_activity($pdo, $ticketId, null, 'Ticket erstellt');
// Verbleibende Regel-Aktionen, die eine vorhandene Ticket-ID benötigen
$assignTo = $ruleResult['assign_staff'];
if ($assignTo === null) {
// Auto-Zuweisung: war der Kunde schon mal einem Mitarbeiter zugewiesen?
$prevStmt = $pdo->prepare(
"SELECT assigned_staff_id FROM tickets
WHERE LOWER(requester_email) = LOWER(?)
AND assigned_staff_id IS NOT NULL
AND status NOT IN ('closed','resolved')
AND id != ?
ORDER BY updated_at DESC LIMIT 1"
);
$prevStmt->execute([$email, $ticketId]);
$prevId = $prevStmt->fetchColumn();
if ($prevId) {
$assignTo = (int) $prevId;
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (letzter Bearbeiter)');
}
} else {
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Ticket-Regel)');
}
if ($assignTo !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) {
apply_tag_to_ticket($pdo, $ticketId, $tagName);
}
if ($ruleResult['matched_rules']) {
log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules']));
}
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult);
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$deptStmt->execute([$deptId]);
$dept = $deptStmt->fetch();
$ticketForNotify = [
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $name,
'requester_email' => $email,
'department_id' => $deptId,
'department_name' => $dept['name'] ?? null,
];
if (!$closedByRule) {
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
notify_staff_new_ticket($ticketForNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
if (file_exists(__DIR__ . '/includes/push.php')) {
require_once __DIR__ . '/includes/push.php';
push_new_ticket($ticketForNotify);
}
notify_customer_ticket_created($ticketForNotify);
}
$_SESSION['guest_ticket_access'][$ticketNumber] = true;
flash('success', "Ihr Ticket #{$ticketNumber} wurde erstellt. Wir melden uns so schnell wie möglich.");
redirect('/ticket_view.php?n=' . urlencode($ticketNumber));
}
}
$pageTitle = 'Neues Ticket';
$activePage = 'new';
require __DIR__ . '/includes/header_customer.php';
?>
<div class="ticket-create-page">
<div class="topbar ticket-create-header">
<div>
<h1>Neues Ticket erstellen</h1>
<p class="muted" style="margin:4px 0 0">Beschreiben Sie Ihr Anliegen so genau wie möglich – das hilft uns, schneller zu helfen.</p>
</div>
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
</div>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php $renderField = function (array $f): void {
$fieldName = 'custom_' . $f['id'];
$posted = $_POST[$fieldName] ?? '';
?>
<?php $__hint = ($f['field_type'] !== 'checkbox' && !empty($f['help_text'])) ? form_help_html($f['help_text'], (string) $f['label']) : ''; ?>
<div class="custom-field-group fw-<?= e($f['form_width'] ?? '') ?: 'full' ?>" data-topic="<?= $f['help_topic_id'] !== null ? (int) $f['help_topic_id'] : '' ?>" data-dept="<?= $f['department_id'] !== null ? (int) $f['department_id'] : '' ?>">
<label for="<?= $fieldName ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' <span style="color:var(--danger)">*</span>' : '' ?></label>
<?php if ($f['field_type'] === 'textarea'): ?>
<textarea id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
<?php elseif ($f['field_type'] === 'select'): ?>
<select id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<option value="">– bitte wählen –</option>
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $opt): $opt = trim($opt); if ($opt === '') continue; ?>
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($f['field_type'] === 'radio'): ?>
<div style="margin-top:4px<?= !empty($f['radio_inline']) ? ';display:flex;flex-wrap:wrap;gap:4px 20px' : '' ?>">
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $ri => $opt): $opt = trim($opt); if ($opt === '') continue; ?>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin:4px 0">
<input type="radio" id="<?= $fieldName ?>_<?= (int) $ri ?>" name="<?= $fieldName ?>" value="<?= e($opt) ?>" style="width:auto" <?= $f['is_required'] ? 'required' : '' ?> <?= $posted === $opt ? 'checked' : '' ?>>
<span><?= e($opt) ?></span>
</label>
<?php endforeach; ?>
</div>
<?php elseif ($f['field_type'] === 'checkbox'): ?>
<label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;margin-top:4px">
<input type="checkbox" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="1" style="width:auto;margin-top:3px" <?= $f['is_required'] ? 'required' : '' ?> <?= !empty($posted) ? 'checked' : '' ?>>
<span><?= !empty($f['help_text']) ? form_help_html($f['help_text'], (string) $f['label']) : e($f['label']) ?></span>
</label>
<?php elseif ($f['field_type'] === 'number'): ?>
<input type="number" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php elseif ($f['field_type'] === 'date'): ?>
<input type="date" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php else: ?>
<input type="text" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php endif; ?>
<?php if ($__hint !== ''): ?><p class="muted" style="font-size:.8rem;margin:4px 0 0"><?= $__hint ?></p><?php endif; ?>
</div>
<?php }; ?>
<?php $renderSlot = function (string $section, bool $wrap = true) use ($deptForms, $renderField): void {
$list = [];
foreach ($deptForms as $df) foreach ($df['fields'] as $ff) if ($ff['_section'] === $section) $list[] = $ff;
if (!$list) return;
if ($wrap) echo '<div class="dept-fields">';
foreach ($list as $ff) $renderField($ff);
if ($wrap) echo '</div>';
}; ?>
<style>
.dept-fields{display:flex;flex-wrap:wrap;gap:14px 16px;margin-bottom:14px}
.dept-fields>*{flex:1 1 100%;min-width:0}
.dept-fields>.fw-half{flex:0 0 calc(50% - 8px)}
.dept-fields>.fw-third{flex:0 0 calc(33.333% - 11px)}
.dept-fields>.fw-twothirds{flex:0 0 calc(66.666% - 6px)}
.dept-fields input[type=text],.dept-fields input[type=email],.dept-fields input[type=number],.dept-fields input[type=date],.dept-fields select,.dept-fields textarea{width:100%;box-sizing:border-box;margin-bottom:0}
@media (max-width:640px){.dept-fields>.fw-half,.dept-fields>.fw-third,.dept-fields>.fw-twothirds{flex:1 1 100%}}
</style>
<div class="card ticket-create-card">
<form method="post" enctype="multipart/form-data" class="ticket-create-form">
<?= csrf_field() ?>
<!-- Honeypot -->
<div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<!-- Schritt 2: Anliegen -->
<div class="ticket-form-section">
<p class="ticket-section-label"><span class="ticket-section-number">2</span><span id="issue-title">Ihr Anliegen</span></p>
<div class="dept-fields">
<?php $renderSlot('topic_top', false); ?>
<div>
<?php if ($useHelpTopics): ?>
<label for="help_topic_id">Kategorie <span style="color:var(--danger)">*</span></label>
<select id="help_topic_id" name="help_topic_id" required onchange="updateTopicFieldVisibility()">
<option value="">– bitte wählen –</option>
<?php foreach ($helpTopics as $t): ?>
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<div class="field-row" style="margin-bottom:14px">
<div>
<label for="department_id">Abteilung <span style="color:var(--danger)">*</span></label>
<select id="department_id" name="department_id" required>
<option value="">– bitte wählen –</option>
<?php foreach ($departments as $d): ?>
<option value="<?= $d['id'] ?>" <?= (($_POST['department_id'] ?? '') == $d['id']) ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="priority_id">Priorität <span style="color:var(--danger)">*</span></label>
<select id="priority_id" name="priority_id" required>
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= (($_POST['priority_id'] ?? '') == $p['id'] || (!isset($_POST['priority_id']) && $p['name']==='Normal')) ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<?php endif; ?>
</div>
<?php $renderSlot('topic', false); ?>
</div>
</div>
<?php if (!$user): ?>
<!-- Schritt 1: Kontaktdaten -->
<div class="ticket-form-section">
<p class="ticket-section-label"><span class="ticket-section-number">1</span><span class="contact-title">Ihre Kontaktdaten</span></p>
<div class="dept-fields">
<?php $renderSlot('contact_top', false); ?>
<div class="fw-half">
<label for="name">Name <span style="color:var(--danger)">*</span></label>
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" placeholder="Max Mustermann" required autofocus>
</div>
<div class="fw-half">
<label for="email">E-Mail-Adresse <span style="color:var(--danger)">*</span></label>
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="max@beispiel.de" required>
</div>
<?php $renderSlot('contact', false); ?>
</div>
<p class="ticket-form-hint">
<a href="/register.php">Kostenloses Konto erstellen</a> um alle Ihre Tickets im Überblick zu behalten.
</p>
</div>
<?php else: ?>
<div class="ticket-form-section" id="contact-extra" style="display:none">
<p class="ticket-section-label"><span class="ticket-section-number">1</span><span class="contact-title">Ihre Kontaktdaten</span></p>
<?php $renderSlot('contact_top'); ?>
<?php $renderSlot('contact'); ?>
</div>
<?php endif; ?>
<!-- Schritt 3: Details -->
<div class="ticket-form-section" id="details-section">
<p class="ticket-section-label"><span class="ticket-section-number"></span><span id="details-title">Ihr Anliegen</span></p>
<div id="dept-intro" style="display:none;margin-bottom:14px"><strong id="dept-title"></strong><p class="muted" id="dept-intro-text" style="margin:4px 0 0"></p></div>
<div class="dept-fields">
<?php $renderSlot('issue_top', false); ?>
<div id="subject-wrap">
<label for="subject"><span id="subject-label">Betreff</span> <span style="color:var(--danger)">*</span></label>
<input type="text" id="subject" name="subject" value="<?= e($_POST['subject'] ?? '') ?>" placeholder="Kurze Zusammenfassung Ihres Problems" required>
</div>
<?php $renderSlot('issue_mid', false); ?>
<div id="message-wrap">
<label for="message"><span id="message-label">Beschreibung</span> <span style="color:var(--danger)">*</span></label>
<textarea id="message" name="message" required placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…" style="min-height:140px"><?= e($_POST['message'] ?? '') ?></textarea>
</div>
<?php $renderSlot('issue', false); ?>
</div>
</div>
<?php if ($customFields): ?>
<div class="ticket-form-section" id="custom-section">
<p class="ticket-section-label"><span class="ticket-section-number"></span>Zusätzliche Angaben</p>
<div class="dept-fields"><?php foreach ($customFields as $f) { $renderField($f); } ?></div>
</div>
<?php endif; ?>
<?php // Eigene Abschnitte der Abteilungsformulare (Abschnittsname frei wählbar)
foreach ($deptForms as $did => $df):
$groups = [];
foreach ($df['fields'] as $ff) if (!in_array($ff['_section'], ['contact', 'contact_top', 'issue', 'issue_top', 'issue_mid', 'topic', 'topic_top'], true)) $groups[$ff['_section_title']][] = $ff;
foreach ($groups as $title => $list): ?>
<div class="ticket-form-section dept-section" data-dept="<?= (int) $did ?>" style="display:none">
<p class="ticket-section-label"><span class="ticket-section-number"></span><?= e($title) ?></p>
<div class="dept-fields"><?php foreach ($list as $ff) $renderField($ff); ?></div>
</div>
<?php endforeach; endforeach; ?>
<!-- Anhang -->
<div class="ticket-form-section ticket-attachment-section" id="attachment-section">
<p class="ticket-section-label"><span class="ticket-section-number"></span>Anhang <span class="ticket-section-optional">(optional)</span></p>
<label for="attachment" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1.5px dashed var(--border);border-radius:var(--radius);cursor:pointer;font-size:.85rem;color:var(--muted);font-weight:400;transition:border-color .15s" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span id="att-label">Datei auswählen</span>
</label>
<input type="file" id="attachment" name="attachment" style="display:none" onchange="document.getElementById('att-label').textContent=this.files[0]?this.files[0].name:'Datei auswählen'">
</div>
<div class="ticket-form-actions">
<p class="ticket-form-hint">Pflichtfelder sind mit <span class="ticket-required">*</span> gekennzeichnet.</p>
<button type="submit" class="btn ticket-submit" id="submit-btn">Ticket erstellen</button>
</div>
</form>
</div>
</div>
<script>
// Formular passt sich der gewählten Abteilung an: Abteilungen mit eigenem Formular zeigen nur ihre Felder und Texte.
var DEPT_FORMS = <?= json_encode($deptForms ? array_map(fn($f) => array_diff_key($f, ['fields' => 1]), $deptForms) : new stdClass(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) ?>;
var TOPIC_DEPT = <?= json_encode($topicDept ?: new stdClass()) ?>;
var DEFAULTS = { subjectLabel: 'Betreff', messageLabel: 'Beschreibung', hint: 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…', submit: 'Ticket erstellen' };
function setShown(el, on) {
if (!el) return;
el.style.display = on ? '' : 'none';
el.querySelectorAll('input, select, textarea').forEach(function (i) {
if (!on) { if (i.required) { i.dataset.wasRequired = '1'; i.required = false; } if (i.type !== 'hidden') i.disabled = true; }
else { if (i.dataset.wasRequired === '1') i.required = true; i.disabled = false; }
});
}
function applyDeptForm() {
var topicSel = document.getElementById('help_topic_id'), deptSel = document.getElementById('department_id');
var topic = topicSel ? topicSel.value : '';
var dept = topicSel ? (TOPIC_DEPT[topic] || '') : (deptSel ? deptSel.value : '');
var form = DEPT_FORMS[dept] || null;
var visible = 0, contactVisible = 0;
document.querySelectorAll('.custom-field-group').forEach(function (g) {
var gd = g.getAttribute('data-dept'), gt = g.getAttribute('data-topic');
var show = form ? gd === String(dept) : (gd === '' && (gt === '' || gt === topic));
setShown(g, show);
if (show && gd === '') visible++;
if (show && gd !== '' && g.closest('#contact-extra')) contactVisible++;
});
var sec = document.getElementById('custom-section');
if (sec) sec.style.display = visible > 0 ? '' : 'none';
document.querySelectorAll('.dept-fields').forEach(function (w) {
w.style.display = Array.prototype.some.call(w.children, function (c) { return c.style.display !== 'none'; }) ? '' : 'none';
});
var cx = document.getElementById('contact-extra');
if (cx) cx.style.display = contactVisible > 0 ? '' : 'none';
document.querySelectorAll('.dept-section').forEach(function (s) { s.style.display = (form && s.getAttribute('data-dept') === String(dept)) ? '' : 'none'; });
document.querySelectorAll('.contact-title').forEach(function (t) { t.textContent = form ? form.contact_title : 'Ihre Kontaktdaten'; });
document.getElementById('issue-title').textContent = form ? form.issue_title : 'Ihr Anliegen';
document.getElementById('details-title').textContent = form ? form.details_title : 'Ihr Anliegen'; document.getElementById('subject-label').textContent = form ? form.subject_label : DEFAULTS.subjectLabel;
document.getElementById('message-label').textContent = form ? form.message_label : DEFAULTS.messageLabel;
document.getElementById('message').placeholder = form ? form.message_hint : DEFAULTS.hint;
document.getElementById('submit-btn').textContent = form ? form.submit_label : DEFAULTS.submit;
setShown(document.getElementById('subject-wrap'), !form || form.subject_show);
setShown(document.getElementById('message-wrap'), !form || form.message_show);
[['subject-wrap', 'subject_width'], ['message-wrap', 'message_width']].forEach(function (p) {
var el = document.getElementById(p[0]);
['fw-full', 'fw-half', 'fw-third', 'fw-twothirds'].forEach(function (k) { el.classList.remove(k); });
el.classList.add('fw-' + ((form && form[p[1]]) || 'full'));
});
setShown(document.getElementById('attachment-section'), !form || form.attachment_show);
var has = form && (form.title || form.intro);
document.getElementById('dept-intro').style.display = has ? '' : 'none';
document.getElementById('dept-title').textContent = form ? form.title : '';
document.getElementById('dept-intro-text').textContent = form ? form.intro : '';
var num = 0;
document.querySelectorAll('.ticket-form-section').forEach(function (s) {
var n = s.querySelector('.ticket-section-number');
if (n && s.style.display !== 'none') n.textContent = ++num;
});
}
function updateTopicFieldVisibility() { applyDeptForm(); }
document.addEventListener('DOMContentLoaded', function () {
var d = document.getElementById('department_id');
if (d) d.addEventListener('change', applyDeptForm);
applyDeptForm();
});
</script>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
+1
View File
@@ -69,6 +69,7 @@ $siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub');
.print-footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #e2e8f0; font-size: 8.5pt; color: #94a3b8; display: flex; justify-content: space-between; }
@media print { .no-print { display: none !important; } @page { margin: 15mm; } }
</style>
<?= site_icon_links() ?>
</head>
<body>