Upload via GUI (267 Dateien)
This commit is contained in:
+958
-906
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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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'; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user