diff --git a/DeskHub-Webseite/.gitignore b/DeskHub-Webseite/.gitignore new file mode 100644 index 00000000..5f45b935 --- /dev/null +++ b/DeskHub-Webseite/.gitignore @@ -0,0 +1,30 @@ +# Laufzeitdaten und Geheimnisse gehören nie ins Repository +/config/config.php +/database/*.sqlite +/database/*.sqlite-* +/database/*.db +/backups/* +!/backups/.htaccess +/uploads/* +!/uploads/.htaccess +/plugins/* +!/plugins/.htaccess +!/plugins/README.md +/assets/themes/* +/app_downloads/* +!/app_downloads/.htaccess +!/app_downloads/index.html +/install/.installed +/cron/*.log +*.log +*.bak +.env +*.pem +# Entwicklung +node_modules/ +/desktop/dist/ +/mobile/build/ +/mobile/.gradle/ +/plugin-webseite/schluessel/ +/plugin-webseite/storage/ +/plugin-webseite/config.php diff --git a/DeskHub-Webseite/.htaccess b/DeskHub-Webseite/.htaccess new file mode 100644 index 00000000..d16f3425 --- /dev/null +++ b/DeskHub-Webseite/.htaccess @@ -0,0 +1,41 @@ + +# ── Bot & Scanner Schutz ────────────────────────────────────────────────── +# Bekannte böswillige Bots blockieren +RewriteCond %{HTTP_USER_AGENT} (masscan|nikto|sqlmap|nmap|zgrab|python-requests/2\.2[0-9]|scrapy|dirbuster|gobuster|nuclei) [NC] +RewriteRule ^ - [F,L] + +# Leere User-Agents blockieren +RewriteCond %{HTTP_USER_AGENT} ^-?$ +RewriteRule ^ - [F,L] + +# Direkt-IP-Zugriff ohne Host verhindern (optional) +# RewriteCond %{HTTP_HOST} !^yourdomain\.com$ +# RewriteRule ^ - [F,L] + +# Clickjacking-Schutz +Header always set X-Frame-Options "SAMEORIGIN" +Header always set X-Content-Type-Options "nosniff" + +# Schützt interne Verzeichnisse vor direktem Web-Zugriff. +# Funktioniert mit Apache (mod_rewrite/mod_authz aktiv). Bei nginx siehe README.md. + +RewriteEngine On +RewriteCond %{REQUEST_URI} ^/(includes|config|database|cron)/ [NC] +RewriteRule ^ - [F,L] + +# Entwicklungs- und Hilfsordner sowie interne Dateien gehören nicht ins Web (Tests, Werkzeuge, Quelltexte der Apps, Paketquellen) +RewriteCond %{REQUEST_URI} ^/(tests|tools|docs|desktop|mobile|theme-packages|vorschaubilder|plugin-webseite|\.git|\.gitea|\.github|backups|plugins/[^/]+/(lib|migrations))(/|$) [NC] +RewriteRule ^ - [F,L] +RewriteCond %{REQUEST_URI} \.(sqlite3?|db|sql|log|bak|old|orig|swp|md|sh|bat|ps1|pem|key|env|ini|lock|dist)$ [NC,OR] +RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC] +RewriteRule ^ - [F,L] + +# version.php nur intern zugänglich +RewriteCond %{REQUEST_URI} ^/version\.php$ [NC] +RewriteRule ^ - [F,L] + + + php_flag display_errors off + + +Options -Indexes diff --git a/DeskHub-Webseite/api/.htaccess b/DeskHub-Webseite/api/.htaccess new file mode 100644 index 00000000..31815df5 --- /dev/null +++ b/DeskHub-Webseite/api/.htaccess @@ -0,0 +1,5 @@ +Options -Indexes + + + Header always set Cache-Control "no-store" + diff --git a/DeskHub-Webseite/api/public/v1/tickets.php b/DeskHub-Webseite/api/public/v1/tickets.php new file mode 100644 index 00000000..2bfe3ea4 --- /dev/null +++ b/DeskHub-Webseite/api/public/v1/tickets.php @@ -0,0 +1,73 @@ + ['code' => $code, 'message' => $message]], JSON_UNESCAPED_UNICODE); + exit; +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +if (!in_array($method, ['GET', 'POST'], true)) { + header('Allow: GET, POST'); + public_api_fail(405, 'METHOD_NOT_ALLOWED', 'Erlaubt sind GET und POST.'); +} + +$key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? '')); +if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1]; +$pdo = get_db(); +$apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null; +if (!$apiKey) public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).'); + +if ($method === 'POST') { + if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.'); + // Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute + $recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?"); + $recent->execute([date('Y-m-d H:i:s', time() - 60)]); + if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.'); + + $raw = file_get_contents('php://input') ?: ''; + if (strlen($raw) > 200000) public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.'); + $body = json_decode($raw, true); + if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.'); + try { + $created = public_ticket_create($pdo, $body, client_ip()); + } catch (AdminModelException $e) { + public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage()); + } + http_response_code(201); + echo json_encode(['number' => $created['number'], 'status' => 'open'], JSON_UNESCAPED_UNICODE); + exit; +} + +// GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden) +if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.'); +$number = trim((string) ($_GET['number'] ?? '')); +$email = strtolower(trim((string) ($_GET['email'] ?? ''))); +if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Parameter number und email sind erforderlich.'); +$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ?'); +$stmt->execute([$number, $email]); +$ticket = $stmt->fetch(); +if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.'); +$threads = $pdo->prepare('SELECT author_type, message, created_at FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 0 ORDER BY created_at, id'); +$threads->execute([$ticket['id']]); +echo json_encode(['ticket' => [ + 'number' => $ticket['ticket_number'], 'subject' => $ticket['subject'], 'status' => $ticket['status'], + 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'], + 'messages' => array_map(fn($t) => ['author' => $t['author_type'] === 'customer' ? 'customer' : 'staff', 'message' => trim(strip_tags((string) $t['message'])), 'createdAt' => $t['created_at']], $threads->fetchAll()), +]], JSON_UNESCAPED_UNICODE); diff --git a/DeskHub-Webseite/api/staff/v1/activity.php b/DeskHub-Webseite/api/staff/v1/activity.php new file mode 100644 index 00000000..a44a8cc8 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/activity.php @@ -0,0 +1,41 @@ + 0, 'newUnassigned' => 0, 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]); +} +if (strtotime($since) === false) { + api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.'); +} +$sinceSql = date('Y-m-d H:i:s', strtotime($since)); + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM tickets + WHERE assigned_staff_id = ? AND updated_at > ? AND status NOT IN ('resolved','closed')" +); +$stmt->execute([$staff['id'], $sinceSql]); +$newReplies = (int) $stmt->fetchColumn(); + +$departmentIds = staff_department_ids($staff); +[$deptFilter, $deptParams] = dept_filter_sql($departmentIds, 'department_id'); +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM tickets + WHERE assigned_staff_id IS NULL AND created_at > ?{$deptFilter}" +); +$stmt->execute([$sinceSql, ...$deptParams]); +$newUnassigned = (int) $stmt->fetchColumn(); + +api_json([ + 'newReplies' => $newReplies, + 'newUnassigned' => $newUnassigned, + 'serverTime' => db_now(), + 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [], +]); diff --git a/DeskHub-Webseite/api/staff/v1/audit_log.php b/DeskHub-Webseite/api/staff/v1/audit_log.php new file mode 100644 index 00000000..c702da66 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/audit_log.php @@ -0,0 +1,36 @@ +query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn(); +$stmt = $pdo->prepare( + 'SELECT al.*, s.name AS staff_name FROM admin_audit_log al + LEFT JOIN staff s ON s.id = al.staff_id + ORDER BY al.created_at DESC LIMIT ? OFFSET ?' +); +$stmt->bindValue(1, $perPage, PDO::PARAM_INT); +$stmt->bindValue(2, $offset, PDO::PARAM_INT); +$stmt->execute(); +$entries = $stmt->fetchAll(); + +api_json([ + 'items' => array_map(fn(array $e) => [ + 'id' => (int) $e['id'], + 'staffName' => $e['staff_name'], + 'action' => $e['action'], + 'createdAt' => $e['created_at'], + ], $entries), + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'hasMore' => $offset + count($entries) < $total, +]); diff --git a/DeskHub-Webseite/api/staff/v1/availability.php b/DeskHub-Webseite/api/staff/v1/availability.php new file mode 100644 index 00000000..0e358242 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/availability.php @@ -0,0 +1,68 @@ +query( + 'SELECT s.id, s.name, s.is_on_vacation, sa.status, sa.note, sa.updated_at + FROM staff s + LEFT JOIN staff_availability sa ON sa.staff_id = s.id + WHERE s.is_active = 1 + ORDER BY s.name' + )->fetchAll(); + + $me = null; + $team = []; + foreach ($agents as $a) { + $entry = [ + 'id' => (int) $a['id'], + 'name' => $a['name'], + 'status' => $a['is_on_vacation'] ? 'offline' : ($a['status'] ?: 'offline'), + 'note' => $a['is_on_vacation'] ? 'Urlaub' : ($a['note'] ?? ''), + 'isOnVacation' => (bool) $a['is_on_vacation'], + 'updatedAt' => $a['updated_at'], + ]; + $team[] = $entry; + if ((int) $a['id'] === (int) $staff['id']) { + // "Mein Status" fällt (wie im Web) auf "available" zurück, solange nie gesetzt – + // reine Anzeige-Vorauswahl, ändert nichts an der "Offline"-Team-Konvention oben. + $me = ['status' => $a['status'] ?: 'available', 'note' => $a['note'] ?? '']; + } + } + + api_json(['myStatus' => $me['status'] ?? 'available', 'myNote' => $me['note'] ?? '', 'team' => $team]); +} + +$body = api_request_body(); +$newStatus = (string) ($body['status'] ?? ''); +$note = trim((string) ($body['note'] ?? '')); + +if (!in_array($newStatus, AVAILABILITY_STATUSES, true)) { + api_error(422, 'INVALID_STATUS', 'Ungültiger Status.'); +} + +$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); +$sql = $drv === 'sqlite' + ? 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?) + ON CONFLICT(staff_id) DO UPDATE SET status=excluded.status, note=excluded.note, updated_at=excluded.updated_at' + : 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?) + ON DUPLICATE KEY UPDATE status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at)'; +$pdo->prepare($sql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]); + +api_json(['ok' => true, 'status' => $newStatus]); diff --git a/DeskHub-Webseite/api/staff/v1/banlist.php b/DeskHub-Webseite/api/staff/v1/banlist.php new file mode 100644 index 00000000..fe98099c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/banlist.php @@ -0,0 +1,100 @@ +query( + 'SELECT b.*, s.name AS added_by_name FROM banlist b LEFT JOIN staff s ON s.id = b.created_by ORDER BY b.created_at DESC' + )->fetchAll(); + + api_json([ + 'items' => array_map(fn(array $b) => [ + 'id' => (int) $b['id'], + 'matchType' => $b['match_type'], + 'value' => $b['value'], + 'reason' => $b['reason'], + 'addedByName' => $b['added_by_name'], + 'createdAt' => $b['created_at'], + ], $entries), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +/** Schließt alle offenen Tickets, die zur gerade gesperrten E-Mail/Domain passen. */ +function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void +{ + $col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email'); + $pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") + ->execute([db_now(), db_now(), $value]); +} + +if ($action === 'create') { + $matchType = ($body['matchType'] ?? 'email') === 'domain' ? 'domain' : 'email'; + $value = trim(strtolower((string) ($body['value'] ?? ''))); + $reason = trim((string) ($body['reason'] ?? '')); + + if ($value === '') { + api_error(422, 'VALUE_REQUIRED', 'Bitte einen Wert angeben.'); + } + if ($matchType === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + api_error(422, 'INVALID_EMAIL', 'Ungültige E-Mail-Adresse.'); + } + if ($matchType === 'domain' && (!str_contains($value, '.') || str_contains($value, '@'))) { + api_error(422, 'INVALID_DOMAIN', 'Nur Domain angeben, z.B. spam.de (ohne @).'); + } + + $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); + $chk->execute([$value]); + if (!$chk->fetchColumn()) { + $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)') + ->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]); + api_close_banned_tickets($pdo, $matchType, $value); + audit_log($staff['id'], "Sperrliste: {$value} hinzugefügt ({$matchType}, Mobile App)"); + } + api_json(['ok' => true]); +} + +if ($action === 'bulk_add') { + $raw = trim((string) ($body['values'] ?? '')); + $reason = trim((string) ($body['reason'] ?? '')); + $lines = array_filter(array_map('trim', preg_split('/[\r\n,;]+/', $raw))); + $added = 0; + $skipped = 0; + foreach ($lines as $val) { + $val = strtolower($val); + $type = str_contains($val, '@') ? 'email' : 'domain'; + if ($type === 'email' && !filter_var($val, FILTER_VALIDATE_EMAIL)) { $skipped++; continue; } + if ($type === 'domain' && !str_contains($val, '.')) { $skipped++; continue; } + $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); + $chk->execute([$val]); + if ($chk->fetchColumn()) { $skipped++; continue; } + $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)') + ->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]); + api_close_banned_tickets($pdo, $type, $val); + $added++; + } + audit_log($staff['id'], "Sperrliste Massen-Import: {$added} hinzugefügt, {$skipped} übersprungen (Mobile App)"); + api_json(['added' => $added, 'skipped' => $skipped]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $val = $pdo->prepare('SELECT value FROM banlist WHERE id=?'); + $val->execute([$id]); + $v = $val->fetchColumn(); + if ($v === false) { + api_error(404, 'ENTRY_NOT_FOUND', 'Der Eintrag wurde nicht gefunden.'); + } + $pdo->prepare('DELETE FROM banlist WHERE id=?')->execute([$id]); + audit_log($staff['id'], "Sperrliste: {$v} entfernt (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/business_hours.php b/DeskHub-Webseite/api/staff/v1/business_hours.php new file mode 100644 index 00000000..fcf3c37c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/business_hours.php @@ -0,0 +1,77 @@ +query('SELECT * FROM business_hours')->fetchAll() as $r) { + $hoursByWeekday[(int) $r['weekday']] = $r; + } + $holidays = $pdo->query('SELECT * FROM business_holidays ORDER BY holiday_date')->fetchAll(); + + api_json([ + 'enabled' => get_setting('business_hours_enabled', '0') === '1', + 'hours' => array_map(function (int $wd) use ($hoursByWeekday) { + $h = $hoursByWeekday[$wd] ?? ['is_open' => 0, 'open_time' => '09:00:00', 'close_time' => '17:00:00']; + return [ + 'weekday' => $wd, + 'isOpen' => (bool) $h['is_open'], + 'openTime' => substr($h['open_time'], 0, 5), + 'closeTime' => substr($h['close_time'], 0, 5), + ]; + }, BUSINESS_WEEKDAYS), + 'holidays' => array_map(fn(array $h) => [ + 'id' => (int) $h['id'], + 'date' => $h['holiday_date'], + 'name' => $h['name'], + ], $holidays), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'save_hours') { + set_setting('business_hours_enabled', !empty($body['enabled']) ? '1' : '0'); + $incoming = (array) ($body['hours'] ?? []); + $byWeekday = []; + foreach ($incoming as $entry) { + $byWeekday[(int) ($entry['weekday'] ?? -1)] = $entry; + } + $stmt = $pdo->prepare('UPDATE business_hours SET is_open=?, open_time=?, close_time=? WHERE weekday=?'); + foreach (BUSINESS_WEEKDAYS as $wd) { + $entry = $byWeekday[$wd] ?? null; + $isOpen = !empty($entry['isOpen']) ? 1 : 0; + $openTime = trim((string) ($entry['openTime'] ?? '09:00')) . ':00'; + $closeTime = trim((string) ($entry['closeTime'] ?? '17:00')) . ':00'; + $stmt->execute([$isOpen, $openTime, $closeTime, $wd]); + } + audit_log($staff['id'], 'Geschäftszeiten aktualisiert (Mobile App)'); + api_json(['ok' => true]); +} + +if ($action === 'add_holiday') { + $date = trim((string) ($body['date'] ?? '')); + $name = trim((string) ($body['name'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) api_error(422, 'INVALID_DATE', 'Bitte ein gültiges Datum angeben.'); + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Feiertag angeben.'); + + $pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$date, $name]); + audit_log($staff['id'], "Feiertag hinzugefügt: {$name} ({$date}) (Mobile App)"); + api_json(['id' => (int) $pdo->lastInsertId()]); +} + +if ($action === 'delete_holiday') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('DELETE FROM business_holidays WHERE id = ?')->execute([$id]); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/canned_responses.php b/DeskHub-Webseite/api/staff/v1/canned_responses.php new file mode 100644 index 00000000..7f509c2a --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/canned_responses.php @@ -0,0 +1,40 @@ +prepare( + "SELECT cr.id, cr.title, cr.body, cr.department_id + FROM canned_responses cr + WHERE 1=1{$deptFilter} + ORDER BY cr.title" +); +$stmt->execute($deptParams); + +$responses = array_map(fn(array $row) => [ + 'id' => (int) $row['id'], + 'title' => $row['title'], + 'body' => api_plain_text($row['body']), + 'departmentId' => $row['department_id'] !== null ? (int) $row['department_id'] : null, +], $stmt->fetchAll()); + +api_json(['items' => $responses]); diff --git a/DeskHub-Webseite/api/staff/v1/chat.php b/DeskHub-Webseite/api/staff/v1/chat.php new file mode 100644 index 00000000..5d744d1c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/chat.php @@ -0,0 +1,105 @@ +prepare('SELECT id, name FROM staff WHERE id = ?'); + $withStmt->execute([$withId]); + $withStaff = $withStmt->fetch(); + if (!$withStaff) { + api_error(404, 'STAFF_NOT_FOUND', 'Der Mitarbeiter wurde nicht gefunden.'); + } + + $pdo->prepare('UPDATE staff_chat_messages SET is_read = 1 WHERE from_staff_id = ? AND to_staff_id = ?') + ->execute([$withId, $staff['id']]); + + $cStmt = $pdo->prepare( + 'SELECT m.*, sf.name AS from_name + FROM staff_chat_messages m + JOIN staff sf ON sf.id = m.from_staff_id + WHERE (m.from_staff_id = ? AND m.to_staff_id = ?) + OR (m.from_staff_id = ? AND m.to_staff_id = ?) + ORDER BY m.created_at ASC LIMIT 300' + ); + $cStmt->execute([$staff['id'], $withId, $withId, $staff['id']]); + $conversation = $cStmt->fetchAll(); + + api_json([ + 'with' => ['id' => (int) $withStaff['id'], 'name' => $withStaff['name']], + 'messages' => array_map(fn(array $m) => [ + 'id' => (int) $m['id'], + 'fromStaffId' => (int) $m['from_staff_id'], + 'fromName' => $m['from_name'], + 'message' => $m['message'], + 'createdAt' => $m['created_at'], + ], $conversation), + ]); + } + + $allStaff = $pdo->prepare('SELECT id, name FROM staff WHERE is_active = 1 AND id != ? ORDER BY name'); + $allStaff->execute([$staff['id']]); + $contacts = $allStaff->fetchAll(); + + $unreadStmt = $pdo->prepare( + 'SELECT from_staff_id, COUNT(*) AS cnt FROM staff_chat_messages WHERE to_staff_id = ? AND is_read = 0 GROUP BY from_staff_id' + ); + $unreadStmt->execute([$staff['id']]); + $unreadByStaff = []; + foreach ($unreadStmt->fetchAll() as $row) { + $unreadByStaff[(int) $row['from_staff_id']] = (int) $row['cnt']; + } + + $lastStmt = $pdo->prepare( + "SELECT CASE WHEN from_staff_id = ? THEN to_staff_id ELSE from_staff_id END AS other_id, + MAX(created_at) AS last_at + FROM staff_chat_messages + WHERE from_staff_id = ? OR to_staff_id = ? + GROUP BY other_id" + ); + $lastStmt->execute([$staff['id'], $staff['id'], $staff['id']]); + $lastByStaff = []; + foreach ($lastStmt->fetchAll() as $row) { + $lastByStaff[(int) $row['other_id']] = $row['last_at']; + } + + api_json([ + 'contacts' => array_map(fn(array $s) => [ + 'id' => (int) $s['id'], + 'name' => $s['name'], + 'unreadCount' => $unreadByStaff[(int) $s['id']] ?? 0, + 'lastMessageAt' => $lastByStaff[(int) $s['id']] ?? null, + ], $contacts), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'send') { + $toId = (int) ($body['toId'] ?? 0); + $message = trim((string) ($body['message'] ?? '')); + + if ($toId === 0) api_error(422, 'RECIPIENT_REQUIRED', 'Bitte einen Empfänger angeben.'); + if ($message === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.'); + if (strlen($message) > 5000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.'); + + $toCheck = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); + $toCheck->execute([$toId]); + if (!(bool) $toCheck->fetchColumn()) { + api_error(422, 'RECIPIENT_NOT_FOUND', 'Der Empfänger wurde nicht gefunden.'); + } + + $pdo->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, created_at) VALUES (?,?,?,?)') + ->execute([$staff['id'], $toId, $message, db_now()]); + api_json(['id' => (int) $pdo->lastInsertId()]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/common.php b/DeskHub-Webseite/api/staff/v1/common.php new file mode 100644 index 00000000..f24d5b38 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/common.php @@ -0,0 +1,346 @@ +getMessage()); + api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.'); +}); + +function api_json(array $payload, int $status = 200): never +{ + http_response_code($status); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function api_error(int $status, string $code, string $message, array $details = []): never +{ + api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status); +} + +function api_require_method(string ...$methods): void +{ + if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) { + header('Allow: ' . implode(', ', $methods)); + api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.'); + } +} + +function api_request_body(): array +{ + $length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); + if ($length > 1024 * 1024) { + api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.'); + } + $raw = file_get_contents('php://input'); + if ($raw === false || trim($raw) === '') { + return []; + } + try { + $body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.'); + } + if (!is_array($body)) { + api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.'); + } + return $body; +} + +function api_require_https(): void +{ + $https = request_is_https(); + $local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true); + $allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1'; + if (!$https && !$local && !$allowInsecure) { + api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.'); + } +} + +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'] ?? ''; + } + return trim($header); +} + +function api_bearer_token(): ?string +{ + $header = api_authorization_header(); + if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) { + return null; + } + return $matches[1]; +} + +function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array +{ + $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30'))); + $expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400)); + $deviceName = trim((string) $deviceName); + if ($deviceName === '') { + $deviceName = null; + } else { + $deviceName = mb_substr($deviceName, 0, 120); + } + + $pdo->prepare( + 'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at) + VALUES (?,?,?,?,?)' + )->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now()]); + + return ['token' => $token, 'expires_at' => $expiresAt]; +} + +function api_require_staff(): array +{ + $token = api_bearer_token(); + if ($token === null) { + api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.'); + } + + $pdo = get_db(); + $stmt = $pdo->prepare( + 'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at + FROM mobile_api_tokens mt + JOIN staff s ON s.id = mt.staff_id + WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1 + LIMIT 1' + ); + $stmt->execute([hash('sha256', $token), db_now()]); + $staff = $stmt->fetch(); + if (!$staff) { + api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.'); + } + + $lastUsed = $staff['mobile_token_last_used_at'] ?? null; + if (!$lastUsed || strtotime($lastUsed) < time() - 300) { + $pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?') + ->execute([db_now(), $staff['mobile_token_id']]); + } + + // "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php): + // auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen. + pseudo_cron_schedule_background(); + + return $staff; +} + +function api_staff_payload(array $staff): array +{ + $permissionNames = array_merge([ + 'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket', + 'create_ticket', 'view_reports', + ], array_keys(admin_section_permissions())); + $permissions = []; + foreach ($permissionNames as $permission) { + $permissions[$permission] = has_permission($staff, $permission); + } + return [ + 'id' => (int) $staff['id'], + 'name' => $staff['name'], + 'email' => $staff['email'], + 'role' => $staff['role'], + 'permissions' => $permissions, + ]; +} + +/** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */ +function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void +{ + if (!has_permission($staff, $permission, $departmentId)) { + api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.'); + } +} + +function api_require_admin(array $staff): void +{ + if ($staff['role'] !== 'admin') { + api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.'); + } +} + +/** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */ +function api_require_admin_or_permission(array $staff, string $permission): void +{ + if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) { + api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.'); + } +} + +function api_find_ticket(PDO $pdo, array $staff, string $number): array +{ + $stmt = $pdo->prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, + s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.ticket_number = ?' + ); + $stmt->execute([$number]); + $ticket = $stmt->fetch(); + if (!$ticket) { + api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.'); + } + $departmentIds = staff_department_ids($staff); + if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) { + api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.'); + } + return $ticket; +} + +function api_plain_text(?string $html): string +{ + $text = preg_replace('//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')); +} + +function api_ticket_summary(array $ticket): array +{ + return [ + 'id' => (int) $ticket['id'], + 'number' => $ticket['ticket_number'], + 'subject' => $ticket['subject'], + 'requesterName' => $ticket['requester_name'], + 'requesterEmail' => $ticket['requester_email'], + 'status' => $ticket['status'], + 'priority' => [ + 'id' => (int) $ticket['priority_id'], + 'name' => $ticket['priority_name'], + 'color' => $ticket['priority_color'], + ], + 'department' => [ + 'id' => (int) $ticket['department_id'], + 'name' => $ticket['department_name'], + ], + 'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [ + 'id' => (int) $ticket['assigned_staff_id'], + 'name' => $ticket['staff_name'], + ], + 'isOverdue' => !empty($ticket['resolution_due_at']) + && strtotime($ticket['resolution_due_at']) < time() + && !in_array($ticket['status'], ['resolved', 'closed'], true), + // Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min + // (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im + // Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus + // den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format). + 'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null, + 'isSpam' => !empty($ticket['is_spam']), + 'createdAt' => $ticket['created_at'], + 'updatedAt' => $ticket['updated_at'], + ]; +} + +function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus): int +{ + $permission = $type === 'note' ? 'post_note' : 'reply_ticket'; + api_require_permission($staff, $permission, (int) $ticket['department_id']); + if (!in_array($type, ['reply', 'note'], true)) { + api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.'); + } + + $message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff)); + if (trim(strip_tags($message)) === '') { + api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.'); + } + if (strlen($message) > 100000) { + api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.'); + } + + $isNote = $type === 'note'; + + // Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf + // einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder + // App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt, + // die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden. + if (!$isNote && $ticket['source'] === 'chat') { + $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); + if ($lockedByOtherName) { + api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); + } + } + + // Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET- + // Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur + // den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein + // anderer Mitarbeiter sie in der Zwischenzeit übernommen hat). + if ($ticket['source'] !== 'chat') { + $lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo); + if ($lockedByOtherName) { + api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet."); + } + } + + $newStatus = $setStatus ?: 'answered'; + if (!$isNote) { + if (!in_array($newStatus, MOBILE_API_STATUSES, true)) { + api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.'); + } + if (in_array($newStatus, ['resolved', 'closed'], true)) { + api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); + } + } + $now = db_now(); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,?,?)' + ); + $stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]); + $threadId = (int) $pdo->lastInsertId(); + + if ($isNote) { + save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)'); + } else { + $firstResponse = empty($ticket['first_responded_at']); + $autoAssign = empty($ticket['assigned_staff_id']); + $sql = 'UPDATE tickets SET status = ?, updated_at = ?' + . ($firstResponse ? ', first_responded_at = ?' : '') + . ($autoAssign ? ', assigned_staff_id = ?' : '') + . ' WHERE id = ?'; + $args = [$newStatus, $now]; + if ($firstResponse) $args[] = $now; + if ($autoAssign) $args[] = $staff['id']; + $args[] = $ticket['id']; + $pdo->prepare($sql)->execute($args); + save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)'); + } + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + + 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(); + } + $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)); + } + webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]); + return $threadId; +} diff --git a/DeskHub-Webseite/api/staff/v1/customer.php b/DeskHub-Webseite/api/staff/v1/customer.php new file mode 100644 index 00000000..a0bc32ce --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/customer.php @@ -0,0 +1,71 @@ +prepare( + 'SELECT u.id, u.name, u.email, u.created_at, o.name AS organization_name + FROM users u LEFT JOIN organizations o ON o.id = u.organization_id + WHERE u.id = ?' + ); + $stmt->execute([$userId]); + $customer = $stmt->fetch(); + if (!$customer) { + api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.'); + } + $email = $customer['email']; + $isGuest = false; +} else { + if ($email === '') { + api_error(422, 'CUSTOMER_IDENTIFIER_REQUIRED', 'Bitte userId oder email angeben.'); + } + $ticketStmt = $pdo->prepare( + 'SELECT MAX(requester_name) AS name, MIN(created_at) AS created_at + FROM tickets WHERE requester_email = ? AND user_id IS NULL' + ); + $ticketStmt->execute([$email]); + $guestRow = $ticketStmt->fetch(); + if (!$guestRow || $guestRow['name'] === null) { + api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.'); + } + $customer = [ + 'id' => null, + 'name' => $guestRow['name'], + 'email' => $email, + 'created_at' => $guestRow['created_at'], + 'organization_name' => null, + ]; + $isGuest = true; +} + +$ticketStmt = $pdo->prepare( + "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, + s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.requester_email = ? OR t.user_id = ? + ORDER BY t.created_at DESC LIMIT 100" +); +$ticketStmt->execute([$email, $userId]); +$tickets = array_map('api_ticket_summary', $ticketStmt->fetchAll()); + +api_json([ + 'customer' => [ + 'id' => $customer['id'] !== null ? (int) $customer['id'] : null, + 'name' => $customer['name'], + 'email' => $customer['email'], + 'organizationName' => $customer['organization_name'], + 'isGuest' => $isGuest, + 'createdAt' => $customer['created_at'], + ], + 'tickets' => $tickets, +]); diff --git a/DeskHub-Webseite/api/staff/v1/customers.php b/DeskHub-Webseite/api/staff/v1/customers.php new file mode 100644 index 00000000..890e20ee --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/customers.php @@ -0,0 +1,55 @@ +prepare($regSql); +$regStmt->execute($regParams); +$registered = array_map(fn(array $row) => [ + 'id' => (int) $row['id'], + 'name' => $row['name'], + 'email' => $row['email'], + 'organizationName' => $row['organization_name'], + 'ticketCount' => (int) $row['ticket_count'], + 'isGuest' => false, +], $regStmt->fetchAll()); + +$guestSql = "SELECT requester_email AS email, MAX(requester_name) AS name, COUNT(*) AS ticket_count + FROM tickets WHERE user_id IS NULL"; +$guestParams = []; +if ($search !== '') { + $guestSql .= ' AND (requester_name LIKE ? OR requester_email LIKE ?)'; + $guestParams = [$like, $like]; +} +$guestSql .= ' GROUP BY requester_email ORDER BY requester_email LIMIT 100'; +$guestStmt = $pdo->prepare($guestSql); +$guestStmt->execute($guestParams); +$guests = array_map(fn(array $row) => [ + 'id' => null, + 'name' => $row['name'], + 'email' => $row['email'], + 'organizationName' => null, + 'ticketCount' => (int) $row['ticket_count'], + 'isGuest' => true, +], $guestStmt->fetchAll()); + +$customers = array_merge($registered, $guests); +usort($customers, fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? '')); +$customers = array_slice($customers, 0, 100); + +api_json(['items' => $customers]); diff --git a/DeskHub-Webseite/api/staff/v1/dashboard.php b/DeskHub-Webseite/api/staff/v1/dashboard.php new file mode 100644 index 00000000..31d4986c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/dashboard.php @@ -0,0 +1,105 @@ +prepare( + "SELECT COUNT(*) FROM tickets + WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilter}" +); +$stmt->execute([$staff['id'], ...$deptParams]); +$myOpen = (int) $stmt->fetchColumn(); + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM tickets + WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ? + AND status NOT IN ('resolved','closed'){$deptFilter}" +); +$stmt->execute([db_now(), ...$deptParams]); +$overdue = (int) $stmt->fetchColumn(); + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM tickets + WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilter}" +); +$stmt->execute($deptParams); +$unassigned = (int) $stmt->fetchColumn(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilter}"); +$stmt->execute($deptParams); +$unanswered = (int) $stmt->fetchColumn(); + +$todayStart = date('Y-m-d 00:00:00'); +$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilter}"); +$stmt->execute([$todayStart, ...$deptParams]); +$resolvedToday = (int) $stmt->fetchColumn(); + +$periodStart = date('Y-m-d 00:00:00', strtotime('-30 days')); +$stmt = $pdo->prepare( + "SELECT + COUNT(*) AS total, + SUM(CASE WHEN resolution_due_at IS NULL OR resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time + FROM tickets + WHERE resolved_at >= ?{$deptFilter}" +); +$stmt->execute([$periodStart, ...$deptParams]); +$slaRow = $stmt->fetch(); +$slaTotal = (int) ($slaRow['total'] ?? 0); +$slaPercent = $slaTotal > 0 ? round(((int) $slaRow['on_time'] / $slaTotal) * 100, 1) : null; + +// "Priorisiert": dieselbe Auswahl wie in staff/dashboard.php ("mir zugewiesen oder +// unbearbeitet"), damit die App-Übersicht dieselben Tickets zeigt wie die Web-Oberfläche. +[$deptFilterAliased, $deptParamsAliased] = dept_filter_sql($departmentIds, 't.department_id'); +$stmt = $pdo->prepare( + "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name + FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id + WHERE t.status NOT IN ('resolved','closed') AND (t.assigned_staff_id = ? OR t.assigned_staff_id IS NULL){$deptFilterAliased} + ORDER BY t.resolution_due_at ASC + LIMIT 12" +); +$stmt->execute([$staff['id'], ...$deptParamsAliased]); +$recent = array_map('api_ticket_summary', $stmt->fetchAll()); + +$response = [ + 'myOpen' => $myOpen, + 'overdue' => $overdue, + 'unassigned' => $unassigned, + 'unanswered' => $unanswered, + 'resolvedToday' => $resolvedToday, + 'slaPercent' => $slaPercent, + 'recent' => $recent, + 'departments' => null, +]; + +// Voller Überblick über alle Abteilungen nur für uneingeschränkten Zugriff (Admin). +if ($departmentIds === null) { + $stmt = $pdo->prepare( + "SELECT d.id, d.name, + COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count, + COUNT(CASE WHEN t.status NOT IN ('resolved','closed') + AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? + THEN 1 END) AS overdue_count + FROM departments d + LEFT JOIN tickets t ON t.department_id = d.id + GROUP BY d.id, d.name + ORDER BY d.name" + ); + $stmt->execute([db_now()]); + $rows = $stmt->fetchAll(); + $response['departments'] = array_map(fn(array $row) => [ + 'name' => $row['name'], + 'open' => (int) $row['open_count'], + 'overdue' => (int) $row['overdue_count'], + ], $rows); +} + +api_json($response); diff --git a/DeskHub-Webseite/api/staff/v1/departments.php b/DeskHub-Webseite/api/staff/v1/departments.php new file mode 100644 index 00000000..02905dd8 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/departments.php @@ -0,0 +1,78 @@ +query( + 'SELECT d.*, m.name AS manager_name FROM departments d LEFT JOIN staff m ON m.id = d.manager_id ORDER BY d.name' + )->fetchAll(); + $emailOptions = $pdo->query( + 'SELECT ea.email, d.name AS department_name FROM email_addresses ea JOIN departments d ON d.id = ea.department_id ORDER BY ea.email' + )->fetchAll(); + $staffOptions = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + + // Mitglieder = Hauptabteilung oder zusätzlich zugewiesen + $membersByDept = []; + $memberRows = $pdo->query( + 'SELECT s.id, s.name, s.department_id AS dept_id, 1 AS is_primary FROM staff s WHERE s.department_id IS NOT NULL + UNION ALL + SELECT s.id, s.name, sd.department_id, 0 FROM staff_department sd JOIN staff s ON s.id = sd.staff_id' + )->fetchAll(); + foreach ($memberRows as $r) { + $membersByDept[(int) $r['dept_id']][(int) $r['id']] ??= ['id' => (int) $r['id'], 'name' => $r['name'], 'isPrimary' => (bool) $r['is_primary']]; + } + + api_json([ + 'emailOptions' => array_map(fn(array $o) => ['email' => $o['email'], 'departmentName' => $o['department_name']], $emailOptions), + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffOptions), + 'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()), + 'items' => array_map(fn(array $d) => [ + 'id' => (int) $d['id'], + 'name' => $d['name'], + 'email' => $d['email'], + 'isDefault' => (bool) $d['is_default'], + 'signature' => $d['signature'], + 'isActive' => (bool) ($d['is_active'] ?? 1), + 'slaPlanId' => !empty($d['sla_plan_id']) ? (int) $d['sla_plan_id'] : null, + 'managerId' => !empty($d['manager_id']) ? (int) $d['manager_id'] : null, + 'managerName' => $d['manager_name'] ?? null, + 'notes' => $d['notes'] ?? null, + 'members' => array_values($membersByDept[(int) $d['id']] ?? []), + 'createdAt' => $d['created_at'] ?? null, + 'updatedAt' => $d['updated_at'] ?? null, + ], $departments), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$id = (int) ($body['id'] ?? 0); + +try { + if ($action === 'save') { + $in = [ + 'name' => $body['name'] ?? '', 'email' => $body['email'] ?? '', 'is_default' => !empty($body['isDefault']), + 'manager_id' => $body['managerId'] ?? null, 'notes' => $body['notes'] ?? '', 'signature' => $body['signature'] ?? '', + ]; + // Fehlende Schlüssel (ältere App-Version) lassen Status und SLA-Plan unverändert + if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']); + if (array_key_exists('slaPlanId', $body)) $in['sla_plan_id'] = $body['slaPlanId']; + $newId = department_save($pdo, $id, $in); + audit_log($staff['id'], ($id === 0 ? 'Abteilung erstellt: ' : 'Abteilung aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)'); + api_json(['id' => $newId]); + } + if ($action === 'delete') { + $deleted = department_delete($pdo, $id); + audit_log($staff['id'], "Abteilung gelöscht: {$deleted} (Mobile App)"); + api_json(['ok' => true]); + } +} catch (AdminModelException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/email_addresses.php b/DeskHub-Webseite/api/staff/v1/email_addresses.php new file mode 100644 index 00000000..54a7e267 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/email_addresses.php @@ -0,0 +1,108 @@ +query( + 'SELECT ea.*, d.name AS department_name, p.name AS priority_name, h.name AS topic_name + FROM email_addresses ea + JOIN departments d ON d.id = ea.department_id + LEFT JOIN priorities p ON p.id = ea.priority_id + LEFT JOIN help_topics h ON h.id = ea.help_topic_id + ORDER BY d.name, ea.email' + )->fetchAll(); + $departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN))); + $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); + $topics = help_topic_options($pdo, false, false); + + $imapByAddress = []; + foreach ($pdo->query('SELECT * FROM imap_accounts WHERE email_address_id IS NOT NULL ORDER BY id')->fetchAll() as $row) { + $imapByAddress[(int) $row['email_address_id']] ??= $row; + } + + api_json([ + 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments), + 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities), + 'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['path']], $topics), + 'items' => array_map(function (array $a) use ($imapByAddress) { + $imap = $imapByAddress[(int) $a['id']] ?? null; + return [ + 'id' => (int) $a['id'], + 'email' => $a['email'], + 'label' => $a['label'], + 'departmentId' => (int) $a['department_id'], + 'departmentName' => $a['department_name'], + 'priorityId' => $a['priority_id'] !== null ? (int) $a['priority_id'] : null, + 'priorityName' => $a['priority_name'], + 'helpTopicId' => $a['help_topic_id'] !== null ? (int) $a['help_topic_id'] : null, + 'helpTopicName' => $a['topic_name'], + 'isDefault' => (bool) $a['is_default'], + 'isActive' => (bool) ($a['is_active'] ?? 1), + 'notes' => $a['notes'] ?? null, + 'updatedAt' => $a['updated_at'] ?? null, + 'imapEnabled' => $imap !== null && (bool) $imap['is_active'], + 'imapHost' => $imap['host'] ?? '', + 'imapPort' => (int) ($imap['port'] ?? 993), + 'imapEncryption' => $imap['encryption'] ?? 'ssl', + 'imapUsername' => $imap['username'] ?? '', + 'imapFolder' => $imap['folder'] ?? 'INBOX', + 'imapAfterFetch' => $imap['after_fetch'] ?? 'seen', + 'imapArchiveFolder' => $imap['archive_folder'] ?? 'Processed', + 'imapSpamFolder' => $imap['spam_folder'] ?? 'Junk', + 'imapHasPassword' => $imap !== null && ($imap['password_enc'] ?? '') !== '', + 'imapLastRunAt' => $imap['last_run_at'] ?? null, + 'smtpEnabled' => (bool) ($a['smtp_enabled'] ?? 0), + 'smtpHost' => $a['smtp_host'] ?? '', + 'smtpPort' => (int) ($a['smtp_port'] ?? 587), + 'smtpEncryption' => $a['smtp_encryption'] ?? 'tls', + 'smtpUsername' => $a['smtp_username'] ?? '', + 'smtpHasPassword' => ($a['smtp_password_enc'] ?? '') !== '', + ]; + }, $addresses), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$id = (int) ($body['id'] ?? 0); + +try { + if ($action === 'save') { + $b = fn(string $k, $d = null) => $body[$k] ?? $d; + $in = [ + 'email' => $b('email', ''), 'label' => $b('label', ''), 'department_id' => $b('departmentId', 0), + 'priority_id' => $b('priorityId'), 'help_topic_id' => $b('helpTopicId'), 'is_default' => !empty($body['isDefault']), + 'notes' => $b('notes', ''), + 'imap_enabled' => !empty($body['imapEnabled']), 'imap_host' => $b('imapHost', ''), 'imap_port' => $b('imapPort', 993), + 'imap_encryption' => $b('imapEncryption', 'ssl'), 'imap_username' => $b('imapUsername', ''), 'imap_password' => $b('imapPassword', ''), + 'imap_folder' => $b('imapFolder', ''), 'imap_after_fetch' => $b('imapAfterFetch', 'seen'), + 'imap_archive_folder' => $b('imapArchiveFolder', ''), 'imap_spam_folder' => $b('imapSpamFolder', ''), + 'smtp_enabled' => !empty($body['smtpEnabled']), 'smtp_host' => $b('smtpHost', ''), 'smtp_port' => $b('smtpPort', 587), + 'smtp_encryption' => $b('smtpEncryption', 'tls'), 'smtp_username' => $b('smtpUsername', ''), 'smtp_password' => $b('smtpPassword', ''), + ]; + if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']); + $newId = email_address_save($pdo, $id, $in); + audit_log($staff['id'], ($id === 0 ? 'E-Mail-Adresse erstellt: ' : 'E-Mail-Adresse aktualisiert: ') . strtolower(trim((string) ($body['email'] ?? ''))) . ' (Mobile App)'); + api_json(['id' => $newId]); + } + + if ($action === 'delete') { + $deleted = email_address_delete($pdo, $id); + audit_log($staff['id'], "E-Mail-Adresse gelöscht: {$deleted} (Mobile App)"); + api_json(['ok' => true]); + } + + // Tests: Test-Mail an die eigene Adresse, IMAP-Verbindung, SMTP-Erreichbarkeit + if ($action === 'sendTest') api_json(['ok' => true, 'message' => email_address_send_test($pdo, $id, (string) $staff['email'])]); + if ($action === 'testImap') api_json(['ok' => true, 'message' => email_address_test_imap($pdo, $id)]); + if ($action === 'testSmtp') api_json(['ok' => true, 'message' => email_address_test_smtp($pdo, $id)]); +} catch (EmailAddressException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/email_templates.php b/DeskHub-Webseite/api/staff/v1/email_templates.php new file mode 100644 index 00000000..7a9a58e0 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/email_templates.php @@ -0,0 +1,84 @@ + [ + 'label' => 'Ticket erstellt (an Kunde)', + 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'department_name', 'ticket_url', 'lookup_url', 'site_name'], + 'defaultSubject' => 'Ihr Ticket #{ticket_number} wurde erstellt', + 'defaultBody' => email_ticket_created_default_body(), + ], + 'new_reply' => [ + 'label' => 'Neue Antwort (an Kunde)', + 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'message', 'ticket_url'], + 'defaultSubject' => '[#{ticket_number}] {subject}', + 'defaultBody' => email_new_reply_default_body(), + ], + 'new_ticket_department' => [ + 'label' => 'Neues Ticket (an Abteilung)', + 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'customer_email', 'department_name', 'ticket_url'], + 'defaultSubject' => 'Neues Ticket [#{ticket_number}] {subject}', + 'defaultBody' => email_new_ticket_department_default_body(), + ], + 'ticket_overdue' => [ + 'label' => 'Ticket überfällig (an Admins)', + 'placeholders' => ['ticket_number', 'subject', 'department_name', 'ticket_url'], + 'defaultSubject' => 'SLA überschritten: Ticket #{ticket_number}', + 'defaultBody' => email_ticket_overdue_default_body(), + ], + ]; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $items = []; + foreach (api_email_templates_definitions() as $key => $tpl) { + $customSubject = get_setting("email_template_{$key}_subject"); + $customBody = get_setting("email_template_{$key}_body"); + $items[] = [ + 'key' => $key, + 'label' => $tpl['label'], + 'placeholders' => $tpl['placeholders'], + 'subject' => $customSubject ?: $tpl['defaultSubject'], + 'body' => $customBody ?: $tpl['defaultBody'], + 'isCustomized' => $customSubject !== null, + ]; + } + api_json(['items' => $items]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$definitions = api_email_templates_definitions(); +$key = (string) ($body['key'] ?? ''); +if (!isset($definitions[$key])) { + api_error(422, 'INVALID_TEMPLATE', 'Unbekannte Vorlage.'); +} + +if ($action === 'save') { + $subject = trim((string) ($body['subject'] ?? '')); + $bodyText = sanitize_staff_html(trim((string) ($body['body'] ?? ''))); + if ($subject === '') api_error(422, 'SUBJECT_REQUIRED', 'Bitte einen Betreff angeben.'); + if (trim(strip_tags($bodyText)) === '') api_error(422, 'BODY_REQUIRED', 'Bitte einen Text angeben.'); + + set_setting("email_template_{$key}_subject", $subject); + set_setting("email_template_{$key}_body", $bodyText); + audit_log($staff['id'], "E-Mail-Vorlage geändert: {$definitions[$key]['label']} (Mobile App)"); + api_json(['ok' => true]); +} + +if ($action === 'reset') { + set_setting("email_template_{$key}_subject", null); + set_setting("email_template_{$key}_body", null); + audit_log($staff['id'], "E-Mail-Vorlage zurückgesetzt: {$definitions[$key]['label']} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/escalation_rules.php b/DeskHub-Webseite/api/staff/v1/escalation_rules.php new file mode 100644 index 00000000..7a9d822b --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/escalation_rules.php @@ -0,0 +1,140 @@ +query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); + $staffList = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + $depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); + + try { + $rules = $pdo->query( + 'SELECT er.*, + p.name AS prio_name, + s.name AS staff_name, + d.name AS dept_name, + cp.name AS cond_prio_name, + (SELECT COUNT(*) FROM escalation_log el WHERE el.rule_id = er.id) AS applied_count + FROM escalation_rules er + LEFT JOIN priorities p ON p.id = er.action_set_priority_id + LEFT JOIN staff s ON s.id = er.action_assign_staff_id + LEFT JOIN departments d ON d.id = er.condition_dept_id + LEFT JOIN priorities cp ON cp.id = er.condition_priority_id + ORDER BY er.sort_order, er.id' + )->fetchAll(); + } catch (Throwable $e) { + $rules = []; + } + + api_json([ + 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities), + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList), + 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $depts), + 'rules' => array_map(fn(array $r) => [ + 'id' => (int) $r['id'], + 'name' => $r['name'], + 'isActive' => (bool) $r['is_active'], + 'triggerType' => $r['trigger_type'], + 'triggerHours' => (int) $r['trigger_hours'], + 'conditionStatus' => $r['condition_status'], + 'conditionPriorityId' => $r['condition_priority_id'] !== null ? (int) $r['condition_priority_id'] : null, + 'conditionPriorityName' => $r['cond_prio_name'], + 'conditionDeptId' => $r['condition_dept_id'] !== null ? (int) $r['condition_dept_id'] : null, + 'conditionDeptName' => $r['dept_name'], + 'actionSetPriorityId' => $r['action_set_priority_id'] !== null ? (int) $r['action_set_priority_id'] : null, + 'actionSetPriorityName' => $r['prio_name'], + 'actionAssignStaffId' => $r['action_assign_staff_id'] !== null ? (int) $r['action_assign_staff_id'] : null, + 'actionAssignStaffName' => $r['staff_name'], + 'actionSetStatus' => $r['action_set_status'], + 'actionNotifyAdmin' => (bool) $r['action_notify_admin'], + 'actionNotifyStaff' => (bool) $r['action_notify_staff'], + 'sortOrder' => (int) $r['sort_order'], + 'appliedCount' => (int) $r['applied_count'], + ], $rules), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'toggle') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('UPDATE escalation_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]); + api_json(['ok' => true]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('DELETE FROM escalation_rules WHERE id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM escalation_log WHERE rule_id = ?')->execute([$id]); + audit_log($staff['id'], "Eskalationsregel gelöscht: #$id (Mobile App)"); + api_json(['ok' => true]); +} + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + $isActive = !empty($body['isActive']) ? 1 : 0; + $triggerType = (string) ($body['triggerType'] ?? 'no_reply'); + $triggerHours = max(1, (int) ($body['triggerHours'] ?? 24)); + $condStatus = trim((string) ($body['conditionStatus'] ?? '')); + $condPrioId = isset($body['conditionPriorityId']) && $body['conditionPriorityId'] !== null ? (int) $body['conditionPriorityId'] : null; + $condDeptId = isset($body['conditionDeptId']) && $body['conditionDeptId'] !== null ? (int) $body['conditionDeptId'] : null; + $actPrioId = isset($body['actionSetPriorityId']) && $body['actionSetPriorityId'] !== null ? (int) $body['actionSetPriorityId'] : null; + $actStaffId = isset($body['actionAssignStaffId']) && $body['actionAssignStaffId'] !== null ? (int) $body['actionAssignStaffId'] : null; + $actStatus = (string) ($body['actionSetStatus'] ?? ''); + $actNotAdmin = !empty($body['actionNotifyAdmin']) ? 1 : 0; + $actNotStaff = !empty($body['actionNotifyStaff']) ? 1 : 0; + $sortOrder = (int) ($body['sortOrder'] ?? 0); + + if ($name === '') { + api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + } + if (!in_array($triggerType, ESCALATION_TRIGGER_TYPES, true)) { + api_error(422, 'INVALID_TRIGGER', 'Ungültiger Trigger.'); + } + if ($actStatus !== '' && !in_array($actStatus, ESCALATION_SET_STATUSES, true)) { + api_error(422, 'INVALID_STATUS', 'Ungültiger Zielstatus.'); + } + + $data = [$name, $isActive, $triggerType, $triggerHours, $condStatus ?: null, + $condPrioId, $condDeptId, $actPrioId, $actStaffId, + $actStatus ?: null, $actNotAdmin, $actNotStaff, $sortOrder]; + + if ($id) { + $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM escalation_rules WHERE id = ?'); + $existsStmt->execute([$id]); + if (!(bool) $existsStmt->fetchColumn()) { + api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.'); + } + $pdo->prepare( + 'UPDATE escalation_rules SET name=?,is_active=?,trigger_type=?,trigger_hours=?, + condition_status=?,condition_priority_id=?,condition_dept_id=?, + action_set_priority_id=?,action_assign_staff_id=?,action_set_status=?, + action_notify_admin=?,action_notify_staff=?,sort_order=? WHERE id=?' + )->execute(array_merge($data, [$id])); + audit_log($staff['id'], "Eskalationsregel geändert: $name (Mobile App)"); + $newId = $id; + } else { + $pdo->prepare( + 'INSERT INTO escalation_rules (name,is_active,trigger_type,trigger_hours, + condition_status,condition_priority_id,condition_dept_id, + action_set_priority_id,action_assign_staff_id,action_set_status, + action_notify_admin,action_notify_staff,sort_order,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + )->execute(array_merge($data, [db_now()])); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Eskalationsregel erstellt: $name (Mobile App)"); + } + api_json(['id' => $newId]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/help_topics.php b/DeskHub-Webseite/api/staff/v1/help_topics.php new file mode 100644 index 00000000..0df682a3 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/help_topics.php @@ -0,0 +1,80 @@ +query( + 'SELECT h.*, d.name AS department_name, p.name AS priority_name, p.color AS priority_color + FROM help_topics h + JOIN departments d ON d.id = h.department_id + JOIN priorities p ON p.id = h.priority_id + ORDER BY h.sort_order, h.name' + )->fetchAll(); + $departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM help_topics')->fetchAll(PDO::FETCH_COLUMN))); + $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); + $names = array_column($topics, 'name', 'id'); + $parents = array_column($topics, 'parent_id', 'id'); + $pathOf = function (int $id) use ($names, $parents): string { + $parts = []; $seen = []; + while ($id && isset($names[$id]) && !isset($seen[$id])) { $seen[$id] = true; array_unshift($parts, $names[$id]); $id = (int) ($parents[$id] ?? 0); } + return implode(' / ', $parts); + }; + + api_json([ + 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments), + 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities), + 'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()), + 'items' => array_map(fn(array $t) => [ + 'id' => (int) $t['id'], + 'name' => $t['name'], + 'departmentId' => (int) $t['department_id'], + 'departmentName' => $t['department_name'], + 'priorityId' => (int) $t['priority_id'], + 'priorityName' => $t['priority_name'], + 'priorityColor' => $t['priority_color'], + 'isActive' => (bool) $t['is_active'], + 'isPublic' => (bool) ($t['is_public'] ?? 1), + 'slaPlanId' => !empty($t['sla_plan_id']) ? (int) $t['sla_plan_id'] : null, + 'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null, + 'path' => $pathOf((int) $t['id']), + 'notes' => $t['notes'] ?? null, + 'sortOrder' => (int) $t['sort_order'], + 'createdAt' => $t['created_at'] ?? null, + 'updatedAt' => $t['updated_at'] ?? null, + ], $topics), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$id = (int) ($body['id'] ?? 0); + +try { + if ($action === 'save') { + $in = [ + 'name' => $body['name'] ?? '', 'department_id' => $body['departmentId'] ?? 0, 'priority_id' => $body['priorityId'] ?? 0, + 'sort_order' => $body['sortOrder'] ?? 0, + ]; + // Fehlende Schlüssel (ältere App-Version) lassen den bisherigen Wert unverändert + foreach (['isActive' => 'is_active', 'isPublic' => 'is_public', 'parentId' => 'parent_id', 'slaPlanId' => 'sla_plan_id', 'notes' => 'notes'] as $camel => $snake) { + if (array_key_exists($camel, $body)) $in[$snake] = $body[$camel]; + } + $newId = help_topic_save($pdo, $id, $in); + audit_log($staff['id'], ($id === 0 ? 'Hilfethema erstellt: ' : 'Hilfethema aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)'); + api_json(['id' => $newId]); + } + if ($action === 'delete') { + $deleted = help_topic_delete($pdo, $id); + audit_log($staff['id'], "Hilfethema gelöscht: {$deleted} (Mobile App)"); + api_json(['ok' => true]); + } +} catch (AdminModelException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/kb.php b/DeskHub-Webseite/api/staff/v1/kb.php new file mode 100644 index 00000000..07c78353 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/kb.php @@ -0,0 +1,123 @@ +query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll(); + $articles = $pdo->query( + 'SELECT a.*, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id=a.category_id ORDER BY a.title' + )->fetchAll(); + + api_json([ + 'categories' => array_map(fn(array $c) => [ + 'id' => (int) $c['id'], + 'name' => $c['name'], + ], $categories), + 'articles' => array_map(fn(array $a) => [ + 'id' => (int) $a['id'], + 'categoryId' => (int) $a['category_id'], + 'categoryName' => $a['category_name'], + 'title' => $a['title'], + 'body' => $a['body'], + 'isPublished' => (bool) $a['is_published'], + 'views' => (int) $a['views'], + ], $articles), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'create_category') { + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + api_error(422, 'NAME_REQUIRED', 'Bitte einen Kategorienamen angeben.'); + } + $slug = slugify($name); + $pdo->prepare('INSERT INTO kb_categories (name, slug, sort_order) VALUES (?,?,0)')->execute([$name, $slug]); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "KB-Kategorie erstellt: {$name} (Mobile App)"); + api_json(['id' => $newId]); +} + +if ($action === 'delete_category') { + $id = (int) ($body['id'] ?? 0); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE category_id = ?'); + $stmt->execute([$id]); + if ((int) $stmt->fetchColumn() > 0) { + api_error(422, 'CATEGORY_NOT_EMPTY', 'Diese Kategorie enthält noch Artikel und kann nicht gelöscht werden.'); + } + $nameStmt = $pdo->prepare('SELECT name FROM kb_categories WHERE id = ?'); + $nameStmt->execute([$id]); + $catName = $nameStmt->fetchColumn(); + $pdo->prepare('DELETE FROM kb_categories WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "KB-Kategorie gelöscht: {$catName} (Mobile App)"); + api_json(['ok' => true]); +} + +if ($action === 'save_article') { + $id = (int) ($body['id'] ?? 0); + $title = trim((string) ($body['title'] ?? '')); + $categoryId = (int) ($body['categoryId'] ?? 0); + $articleBody = sanitize_staff_html((string) ($body['body'] ?? '')); + $isPublished = !empty($body['isPublished']) ? 1 : 0; + + if ($title === '') { + api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.'); + } + if (trim(strip_tags($articleBody)) === '') { + api_error(422, 'BODY_REQUIRED', 'Bitte einen Inhalt angeben.'); + } + $catCheck = $pdo->prepare('SELECT COUNT(*) FROM kb_categories WHERE id = ?'); + $catCheck->execute([$categoryId]); + if (!(bool) $catCheck->fetchColumn()) { + api_error(422, 'INVALID_CATEGORY', 'Bitte eine gültige Kategorie wählen.'); + } + + if ($id === 0) { + $slug = slugify($title); + $base = $slug; + $i = 2; + $check = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE slug = ?'); + while (true) { + $check->execute([$slug]); + if ((int) $check->fetchColumn() === 0) break; + $slug = $base . '-' . $i++; + } + $pdo->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') + ->execute([$categoryId, $title, $slug, $articleBody, $isPublished, db_now(), db_now()]); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "KB-Artikel erstellt: {$title} (Mobile App)"); + } else { + $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE id = ?'); + $existsStmt->execute([$id]); + if (!(bool) $existsStmt->fetchColumn()) { + api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.'); + } + $pdo->prepare('UPDATE kb_articles SET category_id=?, title=?, body=?, is_published=?, updated_at=? WHERE id=?') + ->execute([$categoryId, $title, $articleBody, $isPublished, db_now(), $id]); + $newId = $id; + audit_log($staff['id'], "KB-Artikel aktualisiert: {$title} (Mobile App)"); + } + api_json(['id' => $newId]); +} + +if ($action === 'delete_article') { + $id = (int) ($body['id'] ?? 0); + $titleStmt = $pdo->prepare('SELECT title FROM kb_articles WHERE id = ?'); + $titleStmt->execute([$id]); + $artTitle = $titleStmt->fetchColumn(); + if ($artTitle === false) { + api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.'); + } + $pdo->prepare('DELETE FROM kb_articles WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "KB-Artikel gelöscht: {$artTitle} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/live_chat.php b/DeskHub-Webseite/api/staff/v1/live_chat.php new file mode 100644 index 00000000..a4221683 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/live_chat.php @@ -0,0 +1,185 @@ +prepare( + 'SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id = t.department_id + WHERE t.ticket_number = ? AND t.source = \'chat\'' + . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '') + ); + $stmt->execute(array_merge([$number], $deptFilterParams)); + return $stmt->fetch() ?: null; +} + +function lc_api_messages(PDO $pdo, int $ticketId, int $since): array +{ + $stmt = $pdo->prepare( + 'SELECT tt.id, tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staffName + FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id + WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ? + ORDER BY tt.created_at ASC, tt.id ASC' + ); + $stmt->execute([$ticketId, $since]); + $attStmt = $pdo->prepare('SELECT id, original_name AS name, filesize AS size FROM ticket_attachments WHERE thread_id = ? ORDER BY id'); + return array_map(function (array $m) use ($attStmt) { + $attStmt->execute([$m['id']]); + return [ + 'id' => (int) $m['id'], + 'authorType' => $m['authorType'], + 'authorName' => $m['authorType'] === 'staff' ? ($m['staffName'] ?: 'Support') : null, + 'message' => api_plain_text($m['message']), + 'createdAt' => $m['createdAt'], + 'attachments' => array_map(fn(array $a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'size' => (int) $a['size']], $attStmt->fetchAll()), + ]; + }, $stmt->fetchAll()); +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $action = $_GET['action'] ?? ''; + + if ($action === 'list') { + $listStmt = $pdo->prepare( + "SELECT t.ticket_number AS number, t.requester_name AS name, t.updated_at AS updatedAt, + (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastAuthor, + (SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastMessage + FROM tickets t + WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . " + ORDER BY t.updated_at DESC LIMIT 100" + ); + $listStmt->execute($deptFilterParams); + api_json(['items' => array_map(fn(array $c) => [ + 'number' => $c['number'], + 'name' => $c['name'], + 'preview' => mb_substr(strip_tags((string) $c['lastMessage']), 0, 60), + 'needsReply' => $c['lastAuthor'] === 'customer', + 'updatedAt' => $c['updatedAt'], + ], $listStmt->fetchAll())]); + } + + if ($action === 'messages') { + $number = trim((string) ($_GET['number'] ?? '')); + if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.'); + $ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams); + if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.'); + + $since = (int) ($_GET['since'] ?? 0); + // Sperre übernehmen/auffrischen bzw. feststellen, dass gerade jemand anders aktiv + // dran ist – wie im Web-Postfach (siehe staff/live_chat.php). Lesen bleibt für + // alle möglich, nur das Schreiben wird unten (Aktion "send") verwehrt. + $lockedByOtherName = null; + if ($ticket['status'] !== 'closed') { + $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); + if (!$lockedByOtherName) { + live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo); + } + } + $customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time(); + + api_json([ + 'status' => $ticket['status'], + 'requesterName' => $ticket['requester_name'], + 'requesterEmail' => $ticket['requester_email'], + 'departmentName' => $ticket['department_name'], + 'lockedByOtherName' => $lockedByOtherName, + 'customerTyping' => $customerTyping, + 'messages' => lc_api_messages($pdo, (int) $ticket['id'], $since), + ]); + } + + api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); +} + +// ── POST: send / end / typing ─────────────────────────────────────────────── +$body = api_request_body(); +$action = $body['action'] ?? ''; +$number = trim((string) ($body['number'] ?? '')); +if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.'); +$ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams); +if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.'); + +if ($action === 'typing') { + if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { + $pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?') + ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]); + } + api_json(['ok' => true]); +} + +if ($action === 'send') { + api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']); + if ($ticket['status'] === 'closed') api_error(409, 'CHAT_CLOSED', 'Dieser Chat wurde bereits beendet.'); + if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { + api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); + } + $message = sanitize_staff_html(trim((string) ($body['message'] ?? ''))); + if (trim(strip_tags($message)) === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.'); + if (strlen($message) > 100000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.'); + + $now = db_now(); + $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)' + )->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]); + $threadId = (int) $pdo->lastInsertId(); + + $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === ''); + $updateFirstResponse = $ticket['first_responded_at'] === null; + $sql = 'UPDATE tickets SET status = ?, updated_at = ?' + . ($updateFirstResponse ? ', first_responded_at = ?' : '') + . ($autoAssign ? ', assigned_staff_id = ?' : '') + . ' WHERE id = ?'; + $args = ['answered', $now]; + if ($updateFirstResponse) $args[] = $now; + if ($autoAssign) $args[] = $staff['id']; + $args[] = $ticket['id']; + $pdo->prepare($sql)->execute($args); + if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)'); + + save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat-Antwort gesendet (Mobile App)'); + finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); + + api_json(['id' => $threadId, 'createdAt' => $now, 'status' => 'answered']); +} + +if ($action === 'end') { + api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); + if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { + api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); + } + $now = db_now(); + $pdo->prepare( + "UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" + )->execute([$now, $now, $ticket['id']]); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)'); + finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); + + if ($ticket['requester_email']) { + try { + notify_customer_chat_transcript($ticket, lc_api_messages($pdo, (int) $ticket['id'], 0)); + } catch (Throwable $e) { + error_log('live_chat.php (Mobile App) transcript mail: ' . $e->getMessage()); + } + } + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/login.php b/DeskHub-Webseite/api/staff/v1/login.php new file mode 100644 index 00000000..e5acc006 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/login.php @@ -0,0 +1,44 @@ +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), +]); diff --git a/DeskHub-Webseite/api/staff/v1/logout.php b/DeskHub-Webseite/api/staff/v1/logout.php new file mode 100644 index 00000000..cbdf6bfe --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/logout.php @@ -0,0 +1,10 @@ +prepare('UPDATE mobile_api_tokens SET revoked_at = ? WHERE id = ?') + ->execute([db_now(), $staff['mobile_token_id']]); +audit_log((int) $staff['id'], 'Mobile App: Abmeldung'); +api_json(['ok' => true]); diff --git a/DeskHub-Webseite/api/staff/v1/me.php b/DeskHub-Webseite/api/staff/v1/me.php new file mode 100644 index 00000000..b2a2442f --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/me.php @@ -0,0 +1,7 @@ + api_staff_payload($staff)]); diff --git a/DeskHub-Webseite/api/staff/v1/metadata.php b/DeskHub-Webseite/api/staff/v1/metadata.php new file mode 100644 index 00000000..bd4efa60 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/metadata.php @@ -0,0 +1,45 @@ +prepare('SELECT id, name, is_active FROM departments WHERE 1=1' . $filter . ' ORDER BY name'); +$stmt->execute($params); +$departments = array_map( + fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'] . ($row['is_active'] ? '' : ' (deaktiviert)')], + $stmt->fetchAll() +); +$priorities = array_map( + fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'], 'color' => $row['color']], + $pdo->query('SELECT id, name, color FROM priorities ORDER BY sort_order')->fetchAll() +); +$agents = array_map( + fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']], + $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll() +); +$statuses = array_map(function (string $value): array { + [$label, $color] = status_label($value); + return ['value' => $value, 'label' => $label, 'color' => $color]; +}, MOBILE_API_STATUSES); + +$tags = array_map( + fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']], + $pdo->query('SELECT id, name FROM tags ORDER BY name')->fetchAll() +); + +$health = has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []; + +api_json([ + 'health' => $health, + 'staff' => api_staff_payload($staff), + 'departments' => $departments, + 'priorities' => $priorities, + 'agents' => $agents, + 'statuses' => $statuses, + 'tags' => $tags, + 'liveChatEnabled' => get_setting('live_chat_enabled', '0') === '1', +]); diff --git a/DeskHub-Webseite/api/staff/v1/organizations.php b/DeskHub-Webseite/api/staff/v1/organizations.php new file mode 100644 index 00000000..337e04fc --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/organizations.php @@ -0,0 +1,54 @@ +query( + 'SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id) AS member_count + FROM organizations o ORDER BY o.name' + )->fetchAll(); + api_json(['items' => array_map(fn(array $o) => [ + 'id' => (int) $o['id'], + 'name' => $o['name'], + 'notes' => $o['notes'], + 'memberCount' => (int) $o['member_count'], + ], $organizations)]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + $notes = trim((string) ($body['notes'] ?? '')); + + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + + if ($id === 0) { + $pdo->prepare('INSERT INTO organizations (name, notes, created_at) VALUES (?,?,?)') + ->execute([$name, $notes, db_now()]); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Organisation erstellt: {$name} (Mobile App)"); + } else { + $pdo->prepare('UPDATE organizations SET name=?, notes=? WHERE id=?')->execute([$name, $notes, $id]); + $newId = $id; + audit_log($staff['id'], "Organisation aktualisiert: {$name} (Mobile App)"); + } + api_json(['id' => $newId]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('UPDATE users SET organization_id = NULL WHERE organization_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM organizations WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "Organisation gelöscht: #{$id} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/plugin.php b/DeskHub-Webseite/api/staff/v1/plugin.php new file mode 100644 index 00000000..a9fc9078 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/plugin.php @@ -0,0 +1,18 @@ + ($entry['fn'])($staff, $request), null); +if (!is_array($result)) api_error(500, 'PLUGIN_ERROR', 'Das Plugin hat keine gültige Antwort geliefert.'); +api_json($result); diff --git a/DeskHub-Webseite/api/staff/v1/priorities.php b/DeskHub-Webseite/api/staff/v1/priorities.php new file mode 100644 index 00000000..ec3be9ff --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/priorities.php @@ -0,0 +1,78 @@ +query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); + api_json(['items' => array_map(fn(array $p) => [ + 'id' => (int) $p['id'], + 'name' => $p['name'], + 'color' => $p['color'], + 'responseHours' => (int) $p['response_hours'], + 'resolutionHours' => (int) $p['resolution_hours'], + 'sortOrder' => (int) $p['sort_order'], + 'notes' => $p['notes'] ?? null, + 'createdAt' => $p['created_at'] ?? null, + 'updatedAt' => $p['updated_at'] ?? null, + ], $priorities)]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + $color = trim((string) ($body['color'] ?? '#888888')); + $response = (int) ($body['responseHours'] ?? 0); + $resolution = (int) ($body['resolutionHours'] ?? 0); + $sort = (int) ($body['sortOrder'] ?? 0); + + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + if ($response <= 0 || $resolution <= 0) api_error(422, 'INVALID_HOURS', 'Bitte gültige SLA-Stunden angeben (größer als 0).'); + if ($response > $resolution) api_error(422, 'INVALID_HOURS', 'Die Reaktionszeit darf nicht länger als die Lösungszeit sein.'); + $dup = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE LOWER(name) = LOWER(?) AND id <> ?'); + $dup->execute([$name, $id]); + if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Priorität mit diesem Namen existiert bereits.'); + $notes = null; + if (array_key_exists('notes', $body)) { + $notes = trim((string) $body['notes']) ?: null; + } elseif ($id) { + $n = $pdo->prepare('SELECT notes FROM priorities WHERE id = ?'); + $n->execute([$id]); + $notes = $n->fetchColumn() ?: null; + } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) api_error(422, 'INVALID_COLOR', 'Bitte eine gültige Farbe im Format #RRGGBB angeben.'); + + if ($id === 0) { + $pdo->prepare('INSERT INTO priorities (name, color, response_hours, resolution_hours, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), db_now()]); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Priorität erstellt: {$name} (Mobile App)"); + } else { + $pdo->prepare('UPDATE priorities SET name=?, color=?, response_hours=?, resolution_hours=?, sort_order=?, notes=?, updated_at=? WHERE id=?') + ->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), $id]); + $newId = $id; + audit_log($staff['id'], "Priorität aktualisiert: {$name} (Mobile App)"); + } + api_json(['id' => $newId]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE priority_id = ?'); + $stmt->execute([$id]); + if ((int) $stmt->fetchColumn() > 0) { + api_error(422, 'PRIORITY_IN_USE', 'Diese Priorität wird noch von Tickets verwendet und kann nicht gelöscht werden.'); + } + $pdo->prepare('DELETE FROM priorities WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "Priorität gelöscht: #{$id} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/queues.php b/DeskHub-Webseite/api/staff/v1/queues.php new file mode 100644 index 00000000..cdac6b6c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/queues.php @@ -0,0 +1,42 @@ + array_map(fn(array $q) => [ + 'id' => (int) $q['id'], + 'name' => $q['name'], + 'isShared' => (bool) $q['is_shared'], + 'mine' => (bool) $q['mine'], + 'ownerName' => $q['owner_name'] ?? null, + 'filters' => queue_filters_to_api($q['filters']), + ], queues_for($pdo, (int) $staff['id']))]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +try { + if ($action === 'save') { + $id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared'])); + api_json(['id' => $id]); + } + if ($action === 'delete') { + $qid = (int) ($body['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?'); + $stmt->execute([$qid]); + $row = $stmt->fetch(); + if (!$row) api_error(404, 'NOT_FOUND', 'Ansicht nicht gefunden.'); + if (!queue_can_edit($row, $staff)) api_error(403, 'FORBIDDEN', 'Diese Ansicht gehört einem anderen Mitarbeiter.'); + $pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]); + api_json(['ok' => true]); + } +} catch (AdminModelException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/report_builder.php b/DeskHub-Webseite/api/staff/v1/report_builder.php new file mode 100644 index 00000000..82a11b4f --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/report_builder.php @@ -0,0 +1,53 @@ + für einen gespeicherten Bericht + $in = [ + 'dimension' => $_GET['dimension'] ?? null, 'from' => $_GET['from'] ?? null, 'to' => $_GET['to'] ?? null, + 'metrics' => array_filter(explode(',', (string) ($_GET['metrics'] ?? ''))), 'sort' => $_GET['sort'] ?? 'label', 'desc' => !empty($_GET['desc']), + 'filters' => array_intersect_key($_GET, report_filter_columns()), + ]; + $savedStmt = $pdo->prepare('SELECT r.*, s.name AS owner FROM saved_reports r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.staff_id = ? OR r.is_shared = 1 ORDER BY r.name'); + $savedStmt->execute([$staff['id']]); + $saved = $savedStmt->fetchAll(); + if (isset($_GET['saved'])) { + foreach ($saved as $sr) if ((int) $sr['id'] === (int) $_GET['saved']) $in = json_decode($sr['config'], true) ?: []; + } + $report = report_run($pdo, $in, staff_department_ids($staff)); + api_json([ + 'config' => $report['config'], 'columns' => $report['columns'], 'rows' => $report['rows'], 'totals' => $report['totals'], + 'dimensions' => array_map(fn($k, $d) => ['key' => $k, 'label' => $d[0]], array_keys(report_dimensions($pdo)), report_dimensions($pdo)), + 'metrics' => array_map(fn($k, $m) => ['key' => $k, 'label' => $m[0], 'unit' => $m[2]], array_keys(report_metrics($pdo)), report_metrics($pdo)), + 'saved' => array_map(fn($sr) => ['id' => (int) $sr['id'], 'name' => $sr['name'], 'isShared' => (bool) $sr['is_shared'], 'mine' => (int) $sr['staff_id'] === (int) $staff['id'], 'ownerName' => $sr['owner']], $saved), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +if ($action === 'save') { + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Bericht angeben.'); + $cfg = report_clean_config($pdo, (array) ($body['config'] ?? [])); + $now = db_now(); + $pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)') + ->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($body['isShared']) ? 1 : 0, $now, $now]); + api_json(['id' => (int) $pdo->lastInsertId()]); +} +if ($action === 'delete') { + $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?'); + $stmt->execute([(int) ($body['id'] ?? 0)]); + $row = $stmt->fetch(); + if (!$row) api_error(404, 'NOT_FOUND', 'Bericht nicht gefunden.'); + if ((int) $row['staff_id'] !== (int) $staff['id'] && $staff['role'] !== 'admin') api_error(403, 'FORBIDDEN', 'Dieser Bericht gehört einem anderen Mitarbeiter.'); + $pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$row['id']]); + api_json(['ok' => true]); +} +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/roles.php b/DeskHub-Webseite/api/staff/v1/roles.php new file mode 100644 index 00000000..2bb4e4f9 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/roles.php @@ -0,0 +1,86 @@ +query( + 'SELECT r.*, (SELECT COUNT(*) FROM staff s WHERE s.role_id=r.id) + (SELECT COUNT(*) FROM staff_department sd WHERE sd.role_id=r.id) AS staff_count FROM roles r ORDER BY r.name' + )->fetchAll(); + + api_json([ + 'permissionKeys' => $rolePermissionKeys, + 'defaultAgentPermissions' => default_permissions('agent'), + 'items' => array_map(fn(array $r) => [ + 'id' => (int) $r['id'], + 'name' => $r['name'], + 'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [], + 'staffCount' => (int) $r['staff_count'], + 'isActive' => (bool) ($r['is_active'] ?? 1), + 'notes' => $r['notes'] ?? null, + 'createdAt' => $r['created_at'] ?? null, + 'updatedAt' => $r['updated_at'] ?? null, + ], $roles), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + + $incoming = (array) ($body['permissions'] ?? []); + $perms = []; + foreach ($rolePermissionKeys as $p) { + $perms[$p] = !empty($incoming[$p]); + } + $json = json_encode($perms); + $dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?'); + $dup->execute([$name, $id]); + if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Rolle mit diesem Namen existiert bereits.'); + // Fehlende Schlüssel (ältere App-Version) lassen Status/Notizen unverändert + $cur = null; + if ($id) { + $c = $pdo->prepare('SELECT is_active, notes FROM roles WHERE id = ?'); + $c->execute([$id]); + $cur = $c->fetch() ?: null; + } + $isActive = array_key_exists('isActive', $body) ? (!empty($body['isActive']) ? 1 : 0) : (int) ($cur['is_active'] ?? 1); + $notes = array_key_exists('notes', $body) ? (trim((string) $body['notes']) ?: null) : ($cur['notes'] ?? null); + + if ($id === 0) { + $pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$name, $json, $isActive, $notes, db_now(), db_now()]); + $newId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Rolle erstellt: {$name} (Mobile App)"); + } else { + $pdo->prepare('UPDATE roles SET name=?, permissions=?, is_active=?, notes=?, updated_at=? WHERE id=?')->execute([$name, $json, $isActive, $notes, db_now(), $id]); + $newId = $id; + audit_log($staff['id'], "Rolle aktualisiert: {$name} (Mobile App)"); + } + api_json(['id' => $newId]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('UPDATE staff SET role_id = NULL WHERE role_id = ?')->execute([$id]); + $pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE role_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM roles WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "Rolle gelöscht: #{$id} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/sla_plans.php b/DeskHub-Webseite/api/staff/v1/sla_plans.php new file mode 100644 index 00000000..281c47f0 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/sla_plans.php @@ -0,0 +1,51 @@ +query( + 'SELECT p.*, + (SELECT COUNT(*) FROM departments d WHERE d.sla_plan_id = p.id) AS dept_count, + (SELECT COUNT(*) FROM help_topics h WHERE h.sla_plan_id = p.id) AS topic_count + FROM sla_plans p ORDER BY p.name' + )->fetchAll(); + api_json(['items' => array_map(fn(array $p) => [ + 'id' => (int) $p['id'], + 'name' => $p['name'], + 'responseHours' => (int) $p['response_hours'], + 'resolutionHours' => (int) $p['resolution_hours'], + 'isActive' => (bool) $p['is_active'], + 'notes' => $p['notes'], + 'departmentCount' => (int) $p['dept_count'], + 'helpTopicCount' => (int) $p['topic_count'], + 'updatedAt' => $p['updated_at'], + ], $plans)]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$id = (int) ($body['id'] ?? 0); + +try { + if ($action === 'save') { + $in = ['name' => $body['name'] ?? '', 'response_hours' => $body['responseHours'] ?? 0, 'resolution_hours' => $body['resolutionHours'] ?? 0, 'notes' => $body['notes'] ?? '']; + if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive']; + $newId = sla_plan_save($pdo, $id, $in); + audit_log($staff['id'], ($id === 0 ? 'SLA-Plan erstellt: ' : 'SLA-Plan aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)'); + api_json(['id' => $newId]); + } + if ($action === 'delete') { + $deleted = sla_plan_delete($pdo, $id); + audit_log($staff['id'], "SLA-Plan gelöscht: {$deleted} (Mobile App)"); + api_json(['ok' => true]); + } +} catch (AdminModelException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/staff_manage.php b/DeskHub-Webseite/api/staff/v1/staff_manage.php new file mode 100644 index 00000000..a40d7d01 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/staff_manage.php @@ -0,0 +1,163 @@ +prepare('DELETE FROM staff_department WHERE staff_id = ?')->execute([$staffId]); + $ins = $pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,?,?)'); + $validRoles = array_map('intval', $pdo->query('SELECT id FROM roles')->fetchAll(PDO::FETCH_COLUMN)); + $seen = []; + foreach ($entries as $entry) { + $dId = (int) ($entry['departmentId'] ?? 0); + if ($dId === 0 || $dId === (int) $primaryDeptId || isset($seen[$dId])) continue; + $seen[$dId] = true; + $rid = (int) ($entry['roleId'] ?? 0); + $ins->execute([$staffId, $dId, !empty($entry['notify']) ? 1 : 0, in_array($rid, $validRoles, true) ? $rid : null]); + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $allStaff = $pdo->query( + 'SELECT s.*, d.name AS department_name FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name' + )->fetchAll(); + $departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll(); + $roles = $pdo->query('SELECT id, name FROM roles ORDER BY name')->fetchAll(); + $teams = $pdo->query('SELECT id, name FROM teams ORDER BY name')->fetchAll(); + $teamStmt = $pdo->prepare('SELECT team_id FROM team_members WHERE staff_id = ?'); + + $extraStmt = $pdo->prepare('SELECT department_id, notify, role_id FROM staff_department WHERE staff_id = ?'); + $items = []; + foreach ($allStaff as $s) { + $extraStmt->execute([$s['id']]); + $extra = []; + foreach ($extraStmt->fetchAll() as $row) { + $extra[] = ['departmentId' => (int) $row['department_id'], 'notify' => (bool) $row['notify'], 'roleId' => $row['role_id'] !== null ? (int) $row['role_id'] : null]; + } + $teamStmt->execute([$s['id']]); + $items[] = [ + 'id' => (int) $s['id'], + 'name' => $s['name'], + 'email' => $s['email'], + 'role' => $s['role'], + 'roleId' => $s['role_id'] !== null ? (int) $s['role_id'] : null, + 'departmentId' => $s['department_id'] !== null ? (int) $s['department_id'] : null, + 'departmentName' => $s['department_name'], + 'isActive' => (bool) $s['is_active'], + 'signature' => $s['signature'], + 'extraDepartments' => $extra, + 'teamIds' => array_map('intval', $teamStmt->fetchAll(PDO::FETCH_COLUMN)), + 'createdAt' => $s['created_at'] ?? null, + ]; + } + + api_json([ + 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments), + 'roles' => array_map(fn(array $r) => ['id' => (int) $r['id'], 'name' => $r['name']], $roles), + 'teams' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $teams), + 'canManageTeams' => has_permission($me, 'manage_teams'), + 'items' => $items, + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + $email = trim((string) ($body['email'] ?? '')); + // Nur echte Administratoren dürfen die Administrator-Rolle vergeben – sonst könnte sich ein + // per Co-Admin-Recht (nur manage_staff) berechtigter Agent selbst (oder andere) zum + // Vollzugriff-Admin machen. Beim Bearbeiten durch einen Co-Admin bleibt die vorhandene + // Rolle des Ziel-Kontos unverändert, statt sie stillschweigend auf "Mitarbeiter" zu setzen. + if ($isFullAdmin) { + $role = ($body['role'] ?? '') === 'admin' ? 'admin' : 'agent'; + } elseif ($id !== 0) { + $currentRoleStmt = $pdo->prepare('SELECT role FROM staff WHERE id = ?'); + $currentRoleStmt->execute([$id]); + $role = $currentRoleStmt->fetchColumn() === 'admin' ? 'admin' : 'agent'; + } else { + $role = 'agent'; + } + $roleId = isset($body['roleId']) && $body['roleId'] !== null ? (int) $body['roleId'] : null; + $deptId = isset($body['departmentId']) && $body['departmentId'] !== null ? (int) $body['departmentId'] : null; + $isActive = !empty($body['isActive']) ? 1 : 0; + $password = (string) ($body['password'] ?? ''); + $signature = sanitize_staff_html((string) ($body['signature'] ?? '')); + + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) api_error(422, 'INVALID_EMAIL', 'Bitte eine gültige E-Mail-Adresse angeben.'); + + $dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id <> ?'); + $dup->execute([$email, $id]); + if ((int) $dup->fetchColumn() > 0) { + api_error(422, 'EMAIL_TAKEN', 'Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits.'); + } + if ($id !== 0 && $id === (int) $me['id'] && !$isActive) { + api_error(422, 'CANNOT_DEACTIVATE_SELF', 'Sie können Ihr eigenes Konto nicht deaktivieren.'); + } + + if ($id === 0) { + $pwErr = validate_password_strength($password); + if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr); + + $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, signature, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?)') + ->execute([$name, $email, password_hash($password, PASSWORD_DEFAULT), $role, $roleId, $deptId, $signature ?: null, $isActive, db_now()]); + $newId = (int) $pdo->lastInsertId(); + audit_log($me['id'], "Mitarbeiter angelegt: {$name} ({$email}) (Mobile App)"); + } else { + $newId = $id; + if ($password !== '') { + $pwErr = validate_password_strength($password); + if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr); + $pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=?, password_hash=? WHERE id=?') + ->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, password_hash($password, PASSWORD_DEFAULT), $id]); + } else { + $pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=? WHERE id=?') + ->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, $id]); + } + audit_log($me['id'], "Mitarbeiter aktualisiert: {$name} ({$email}) (Mobile App)"); + } + // Optional im selben Aufruf: erweiterter Zugriff und Team-Mitgliedschaften + if (array_key_exists('extraDepartments', $body)) { + api_save_staff_access($pdo, $newId, $deptId, (array) $body['extraDepartments']); + } + if (array_key_exists('teamIds', $body) && has_permission($me, 'manage_teams')) { + $pdo->prepare('DELETE FROM team_members WHERE staff_id = ?')->execute([$newId]); + $ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)'); + foreach (array_unique(array_filter(array_map('intval', (array) $body['teamIds']))) as $tid) $ins->execute([$tid, $newId]); + } + api_json(['id' => $newId]); +} + +if ($action === 'save_access') { + $id = (int) ($body['id'] ?? 0); + $stmt = $pdo->prepare('SELECT department_id FROM staff WHERE id = ?'); + $stmt->execute([$id]); + $primaryDeptId = (int) $stmt->fetchColumn(); + + api_save_staff_access($pdo, $id, $primaryDeptId, (array) ($body['extraDepartments'] ?? [])); + audit_log($me['id'], "Abteilungszugriff geändert für Mitarbeiter #{$id} (Mobile App)"); + api_json(['ok' => true]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + if ($id === (int) $me['id']) { + api_error(422, 'CANNOT_DELETE_SELF', 'Sie können Ihr eigenes Konto nicht löschen.'); + } + $pdo->prepare('UPDATE tickets SET assigned_staff_id = NULL WHERE assigned_staff_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM staff WHERE id = ?')->execute([$id]); + audit_log($me['id'], "Mitarbeiter gelöscht: #{$id} (Mobile App)"); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/tasks.php b/DeskHub-Webseite/api/staff/v1/tasks.php new file mode 100644 index 00000000..aa77299c --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/tasks.php @@ -0,0 +1,93 @@ +prepare($sql); + $stmt->execute($params); + $tasks = $stmt->fetchAll(); + + $allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + + api_json([ + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff), + 'items' => array_map(fn(array $t) => [ + 'id' => (int) $t['id'], + 'title' => $t['title'], + 'description' => $t['description'], + 'status' => $t['status'], + 'assignedToId' => $t['assigned_to'] !== null ? (int) $t['assigned_to'] : null, + 'assignedName' => $t['assigned_name'], + 'creatorName' => $t['creator_name'], + 'dueAt' => $t['due_at'], + 'ticketNumber' => $t['ticket_number'], + 'ticketSubject' => $t['ticket_subject'], + ], $tasks), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'create') { + $title = trim((string) ($body['title'] ?? '')); + $desc = trim((string) ($body['description'] ?? '')); + $assignTo = isset($body['assignedToId']) && $body['assignedToId'] !== null ? (int) $body['assignedToId'] : null; + $dueAt = trim((string) ($body['dueAt'] ?? '')) ?: null; + $ticketNumber = trim((string) ($body['ticketNumber'] ?? '')); + + if ($title === '') api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.'); + + $ticketId = null; + if ($ticketNumber !== '') { + $tStmt = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?'); + $tStmt->execute([$ticketNumber]); + $ticketId = $tStmt->fetchColumn() ?: null; + if ($ticketId === null) api_error(422, 'TICKET_NOT_FOUND', 'Das verknüpfte Ticket wurde nicht gefunden.'); + $ticketId = (int) $ticketId; + } + + $pdo->prepare('INSERT INTO tasks (ticket_id,title,description,assigned_to,due_at,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)') + ->execute([$ticketId, $title, $desc ?: null, $assignTo, $dueAt, $staff['id'], db_now(), db_now()]); + api_json(['id' => (int) $pdo->lastInsertId()]); +} + +if ($action === 'update_status') { + $id = (int) ($body['id'] ?? 0); + $status = in_array($body['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $body['status'] : 'open'; + $pdo->prepare('UPDATE tasks SET status=?, updated_at=? WHERE id=?')->execute([$status, db_now(), $id]); + api_json(['ok' => true]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('DELETE FROM tasks WHERE id=?')->execute([$id]); + api_json(['ok' => true]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/teams.php b/DeskHub-Webseite/api/staff/v1/teams.php new file mode 100644 index 00000000..3e0042a6 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/teams.php @@ -0,0 +1,69 @@ +query( + 'SELECT t.*, s.name AS lead_name, + (SELECT COUNT(*) FROM team_members tm WHERE tm.team_id = t.id) AS member_count + FROM teams t LEFT JOIN staff s ON s.id = t.lead_id ORDER BY t.name' + )->fetchAll(); + $allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); + + $memberStmt = $pdo->prepare('SELECT staff_id FROM team_members WHERE team_id = ?'); + $teamsOut = []; + foreach ($teams as $t) { + $memberStmt->execute([$t['id']]); + $teamsOut[] = [ + 'id' => (int) $t['id'], + 'name' => $t['name'], + 'leadId' => $t['lead_id'] !== null ? (int) $t['lead_id'] : null, + 'leadName' => $t['lead_name'], + 'notes' => $t['notes'], + 'isActive' => (bool) $t['is_active'], + 'noAlerts' => (bool) ($t['no_alerts'] ?? 0), + 'createdAt' => $t['created_at'] ?? null, + 'updatedAt' => $t['updated_at'] ?? null, + 'memberCount' => (int) $t['member_count'], + 'memberIds' => array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN)), + ]; + } + + api_json([ + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff), + 'items' => $teamsOut, + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; +$id = (int) ($body['id'] ?? 0); + +try { + if ($action === 'save') { + $in = ['name' => $body['name'] ?? '', 'lead_id' => $body['leadId'] ?? null, 'notes' => $body['notes'] ?? '', 'members' => $body['memberIds'] ?? []]; + if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive']; + if (array_key_exists('noAlerts', $body)) $in['no_alerts'] = $body['noAlerts']; + $newId = team_save($pdo, $id, $in); + audit_log($staff['id'], ($id === 0 ? 'Team erstellt: ' : 'Team aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)'); + api_json(['id' => $newId]); + } + if ($action === 'toggle') { + $pdo->prepare('UPDATE teams SET is_active = 1 - is_active, updated_at = ? WHERE id = ?')->execute([db_now(), $id]); + api_json(['ok' => true]); + } + if ($action === 'delete') { + $deleted = team_delete($pdo, $id); + audit_log($staff['id'], "Team gelöscht: {$deleted} (Mobile App)"); + api_json(['ok' => true]); + } +} catch (AdminModelException $e) { + api_error($e->httpStatus(), $e->errorCode(), $e->getMessage()); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/ticket.php b/DeskHub-Webseite/api/staff/v1/ticket.php new file mode 100644 index 00000000..d464ffc0 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket.php @@ -0,0 +1,93 @@ +prepare( + 'SELECT tt.*, s.name AS staff_name, u.name AS user_name + FROM ticket_threads tt + LEFT JOIN staff s ON s.id = tt.staff_id + LEFT JOIN users u ON u.id = tt.user_id + WHERE tt.ticket_id = ? + ORDER BY tt.created_at ASC, tt.id ASC' +); +$threadStmt->execute([$ticket['id']]); +$attachmentStmt = $pdo->prepare( + 'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id' +); +$commentStmt = $pdo->prepare( + 'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name + FROM ticket_thread_comments tc + JOIN staff s ON s.id = tc.staff_id + WHERE tc.thread_id = ? ORDER BY tc.created_at, tc.id' +); +$threads = []; +foreach ($threadStmt->fetchAll() as $row) { + $attachmentStmt->execute([$row['id']]); + $commentStmt->execute([$row['id']]); + $threads[] = [ + 'id' => (int) $row['id'], + 'authorType' => $row['author_type'], + 'authorName' => $row['staff_name'] ?: $row['user_name'] ?: $ticket['requester_name'], + 'message' => api_plain_text($row['message']), + 'isInternalNote' => (bool) $row['is_internal_note'], + 'createdAt' => $row['created_at'], + 'attachments' => array_map(fn(array $item) => [ + 'id' => (int) $item['id'], + 'name' => $item['original_name'], + 'size' => (int) $item['filesize'], + ], $attachmentStmt->fetchAll()), + 'comments' => array_map(fn(array $item) => [ + 'id' => (int) $item['id'], + 'message' => api_plain_text($item['message']), + 'staff' => ['id' => (int) $item['staff_id'], 'name' => $item['staff_name']], + 'createdAt' => $item['created_at'], + ], $commentStmt->fetchAll()), + ]; +} + +// Live-Chat-Sperre: wie im Web-Backend (staff/live_chat.php) "besitzt" der Mitarbeiter, +// der den Chat gerade ansieht, ihn für ein paar Minuten – verhindert, dass App und Web +// gleichzeitig unbemerkt im selben Chat antworten. Nur für Live-Chat-Tickets relevant, +// normale Tickets bleiben unangetastet. Lesen bleibt für alle möglich, nur das +// Schreiben wird in api_add_ticket_message() serverseitig blockiert. +$lockedByOtherName = null; +if ($ticket['source'] === 'chat' && $ticket['status'] !== 'closed') { + $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); + if (!$lockedByOtherName) { + live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo); + } +} + +$summary = api_ticket_summary($ticket); +$summary['source'] = $ticket['source']; +$summary['firstResponseDueAt'] = $ticket['first_response_due_at']; +$summary['resolutionDueAt'] = $ticket['resolution_due_at']; +$summary['lockedByOtherName'] = $lockedByOtherName; +$summary['threads'] = $threads; +// Rechte für GENAU dieses Ticket: gelten pro Abteilung (Rolle des Mitarbeiters in der Abteilung des Tickets) +$summary['permissions'] = []; +foreach (['reply_ticket', 'post_note', 'close_ticket', 'assign_ticket', 'transfer_ticket', 'delete_ticket'] as $__perm) { + $summary['permissions'][$__perm] = has_permission($staff, $__perm, (int) $ticket['department_id']); +} +api_json(['ticket' => $summary]); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_create.php b/DeskHub-Webseite/api/staff/v1/ticket_create.php new file mode 100644 index 00000000..5fde3b77 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket_create.php @@ -0,0 +1,108 @@ +prepare('SELECT * FROM departments WHERE id = ?'); +$deptStmt->execute([$departmentId]); +$department = $deptStmt->fetch(); +if ($department && empty($department['is_active'])) { + api_error(422, 'DEPARTMENT_INACTIVE', 'Diese Abteilung ist deaktiviert. Bitte eine aktive Abteilung wählen.'); +} +if (!$department) { + api_error(422, 'INVALID_DEPARTMENT', 'Die Abteilung ist ungültig.'); +} + +$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); +$prioStmt->execute([$priorityId]); +$priority = $prioStmt->fetch(); +if (!$priority) { + api_error(422, 'INVALID_PRIORITY', 'Die Priorität ist ungültig.'); +} + +$sla = calc_sla_dates($priority, null, (int) $departmentId, null); +$now = db_now(); +$ticketNumber = generate_ticket_number($pdo); + +$userId = null; +if ($requesterEmail !== '') { + $userStmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)'); + $userStmt->execute([$requesterEmail]); + $userId = $userStmt->fetchColumn() ?: null; +} + +$pdo->beginTransaction(); +try { + $pdo->prepare( + 'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, + priority_id, status, source, assigned_staff_id, + first_response_due_at, resolution_due_at, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + )->execute([ + $ticketNumber, $subject, $userId, $requesterName, $requesterEmail, $departmentId, + $priorityId, 'open', 'staff', $assignedStaffId, + $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now, + ]); + $ticketId = (int) $pdo->lastInsertId(); + + $pdo->prepare( + 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) + VALUES (?,?,?,?,0,?)' + )->execute([$ticketId, 'staff', $staff['id'], $message, $now]); + + log_activity($pdo, $ticketId, (int) $staff['id'], 'Ticket durch Mitarbeiter erstellt (Mobile App)'); + $pdo->commit(); +} catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; +} + +if ($requesterEmail !== '' && $notifyCustomer) { + notify_customer_ticket_created([ + 'ticket_number' => $ticketNumber, + 'subject' => $subject, + 'requester_name' => $requesterName, + 'requester_email' => $requesterEmail, + 'department_id' => $departmentId, + 'department_name' => $department['name'], + ]); +} + +$ticket = api_find_ticket($pdo, $staff, $ticketNumber); +webhook_dispatch($pdo, 'ticket.created', (int) $ticket['id']); +api_json(['ticket' => api_ticket_summary($ticket)], 201); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_message.php b/DeskHub-Webseite/api/staff/v1/ticket_message.php new file mode 100644 index 00000000..7efee484 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket_message.php @@ -0,0 +1,18 @@ + $threadId], 201); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_rules.php b/DeskHub-Webseite/api/staff/v1/ticket_rules.php new file mode 100644 index 00000000..7b20f10f --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket_rules.php @@ -0,0 +1,176 @@ + 'Betreff', 'message' => 'Nachricht', 'requester_email' => 'Absender-E-Mail', 'requester_name' => 'Absender-Name', 'source' => 'Kanal (web/email)']; +const RULE_OPERATORS = ['contains' => 'enthält', 'not_contains' => 'enthält nicht', 'equals' => 'ist genau', 'starts_with' => 'beginnt mit', 'ends_with' => 'endet mit']; +const RULE_ACTIONS = ['set_department' => 'Abteilung setzen', 'set_priority' => 'Priorität setzen', 'set_help_topic' => 'Hilfe-Thema setzen', 'assign_staff' => 'Mitarbeiter zuweisen', 'add_tag' => 'Tag hinzufügen', 'auto_reply' => 'Auto-Antwort senden', 'reject' => 'Ticket verwerfen (z.B. Spam)']; + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $departments = departments_for_select($pdo, array_map('intval', $pdo->query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN))); + $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll(); + $helpTopics = $pdo->query('SELECT id, name FROM help_topics ORDER BY name')->fetchAll(); + $staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll(); + $cannedResponses = $pdo->query('SELECT id, title FROM canned_responses ORDER BY title')->fetchAll(); + + $deptById = array_column($departments, 'name', 'id'); + $prioById = array_column($priorities, 'name', 'id'); + $topicById = array_column($helpTopics, 'name', 'id'); + $staffById = array_column($staffList, 'name', 'id'); + $cannedById = array_column($cannedResponses, 'title', 'id'); + + $rules = $pdo->query('SELECT * FROM ticket_rules ORDER BY sort_order, id')->fetchAll(); + $condStmt = $pdo->prepare('SELECT field, operator, value FROM ticket_rule_conditions WHERE rule_id = ? ORDER BY id'); + $actStmt = $pdo->prepare('SELECT action_type, action_value FROM ticket_rule_actions WHERE rule_id = ? ORDER BY id'); + + $rulesOut = []; + foreach ($rules as $r) { + $condStmt->execute([$r['id']]); + $actStmt->execute([$r['id']]); + + $actionsOut = []; + foreach ($actStmt->fetchAll() as $a) { + $type = $a['action_type']; + $value = $a['action_value']; + $label = match ($type) { + 'set_department' => $deptById[$value] ?? null, + 'set_priority' => $prioById[$value] ?? null, + 'set_help_topic' => $topicById[$value] ?? null, + 'assign_staff' => $staffById[$value] ?? null, + 'auto_reply' => $cannedById[$value] ?? null, + 'add_tag' => $value, + 'reject' => null, + default => null, + }; + $actionsOut[] = ['type' => $type, 'value' => $value, 'valueLabel' => $label]; + } + + $rulesOut[] = [ + 'id' => (int) $r['id'], + 'name' => $r['name'], + 'matchType' => $r['match_type'], + 'isActive' => (bool) $r['is_active'], + 'sortOrder' => (int) $r['sort_order'], + 'conditions' => $condStmt->fetchAll(), + 'actions' => $actionsOut, + ]; + } + + api_json([ + 'fieldOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_FIELDS), RULE_FIELDS), + 'operatorOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_OPERATORS), RULE_OPERATORS), + 'actionOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_ACTIONS), RULE_ACTIONS), + 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments), + 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities), + 'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $helpTopics), + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList), + 'cannedResponses' => array_map(fn(array $c) => ['id' => (int) $c['id'], 'name' => $c['title']], $cannedResponses), + 'rules' => $rulesOut, + 'autoCloseEnabled' => get_setting('auto_close_enabled', '0') === '1', + 'autoCloseDays' => (int) get_setting('auto_close_days', '7'), + ]); +} + +$body = api_request_body(); +$action = $body['action'] ?? ''; + +if ($action === 'toggle') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('UPDATE ticket_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]); + api_json(['ok' => true]); +} + +if ($action === 'save_auto_close') { + $enabled = !empty($body['enabled']) ? '1' : '0'; + $days = max(1, min(90, (int) ($body['days'] ?? 7))); + set_setting('auto_close_enabled', $enabled); + set_setting('auto_close_days', (string) $days); + audit_log($staff['id'], 'Automatisches Schließen geändert (Mobile App)'); + api_json(['ok' => true]); +} + +if ($action === 'delete') { + $id = (int) ($body['id'] ?? 0); + $pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM ticket_rules WHERE id = ?')->execute([$id]); + audit_log($staff['id'], "Ticket-Regel gelöscht: #$id (Mobile App)"); + api_json(['ok' => true]); +} + +if ($action === 'save') { + $id = (int) ($body['id'] ?? 0); + $name = trim((string) ($body['name'] ?? '')); + $matchType = ($body['matchType'] ?? 'all') === 'any' ? 'any' : 'all'; + $isActive = !empty($body['isActive']) ? 1 : 0; + $sortOrder = (int) ($body['sortOrder'] ?? 0); + $rawConditions = is_array($body['conditions'] ?? null) ? $body['conditions'] : []; + $rawActions = is_array($body['actions'] ?? null) ? $body['actions'] : []; + + if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); + + $conditions = []; + foreach ($rawConditions as $c) { + $field = (string) ($c['field'] ?? ''); + $operator = (string) ($c['operator'] ?? ''); + $value = trim((string) ($c['value'] ?? '')); + if ($value === '' || !isset(RULE_FIELDS[$field]) || !isset(RULE_OPERATORS[$operator])) continue; + $conditions[] = ['field' => $field, 'operator' => $operator, 'value' => $value]; + } + if (!$conditions) api_error(422, 'CONDITIONS_REQUIRED', 'Bitte mindestens eine Bedingung mit einem Wert angeben.'); + + $actionsToSave = []; + foreach ($rawActions as $a) { + $type = (string) ($a['type'] ?? ''); + if (!isset(RULE_ACTIONS[$type])) continue; + $value = $type === 'reject' ? '1' : trim((string) ($a['value'] ?? '')); + if ($type !== 'reject' && $value === '') continue; + $actionsToSave[] = ['type' => $type, 'value' => $value]; + } + if (!$actionsToSave) api_error(422, 'ACTIONS_REQUIRED', 'Bitte mindestens eine Aktion festlegen.'); + + if ($id) { + $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_rules WHERE id = ?'); + $existsStmt->execute([$id]); + if (!(bool) $existsStmt->fetchColumn()) api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.'); + $pdo->prepare('UPDATE ticket_rules SET name=?, match_type=?, is_active=?, sort_order=? WHERE id=?') + ->execute([$name, $matchType, $isActive, $sortOrder, $id]); + $pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]); + $pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]); + audit_log($staff['id'], "Ticket-Regel aktualisiert: $name (Mobile App)"); + $ruleId = $id; + } else { + $pdo->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at) VALUES (?,?,?,?,?)') + ->execute([$name, $matchType, $isActive, $sortOrder, db_now()]); + $ruleId = (int) $pdo->lastInsertId(); + audit_log($staff['id'], "Ticket-Regel erstellt: $name (Mobile App)"); + } + + $condStmt = $pdo->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)'); + foreach ($conditions as $c) { + $condStmt->execute([$ruleId, $c['field'], $c['operator'], $c['value']]); + } + $actStmt = $pdo->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)'); + foreach ($actionsToSave as $a) { + $actStmt->execute([$ruleId, $a['type'], $a['value']]); + } + + api_json(['id' => $ruleId]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_spam.php b/DeskHub-Webseite/api/staff/v1/ticket_spam.php new file mode 100644 index 00000000..688fea9d --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket_spam.php @@ -0,0 +1,19 @@ + api_ticket_summary($updated), 'moved' => $result['moved'], 'failed' => $result['failed']]); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_update.php b/DeskHub-Webseite/api/staff/v1/ticket_update.php new file mode 100644 index 00000000..5e40aca0 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/ticket_update.php @@ -0,0 +1,102 @@ +prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); + $agentStmt->execute([$assignedId]); + if (!(bool) $agentStmt->fetchColumn()) { + api_error(422, 'INVALID_ASSIGNEE', 'Der ausgewählte Mitarbeiter ist nicht verfügbar.'); + } + } + $currentId = $ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id']; + if ($assignedId !== $currentId) { + api_require_permission($staff, 'assign_ticket', (int) $ticket['department_id']); + $updates[] = 'assigned_staff_id = ?'; + $params[] = $assignedId; + } +} + +if (!$updates) { + api_json(['ticket' => api_ticket_summary($ticket)]); +} +$updates[] = 'updated_at = ?'; +$params[] = $now; +$params[] = $ticket['id']; +$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params); +if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) { + [$label] = status_label((string) $body['status']); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)'); +} +if (array_key_exists('assignedStaffId', $body)) { + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Zuweisung geändert (Mobile App)'); +} +if ($chatJustClosed && $ticket['requester_email']) { + try { + $transcriptStmt = $pdo->prepare( + 'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name + FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id + WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC' + ); + $transcriptStmt->execute([$ticket['id']]); + $transcriptMessages = array_map(fn(array $m) => [ + 'authorType' => $m['authorType'], + 'authorName' => $m['staff_name'], + 'message' => $m['message'], + 'createdAt' => $m['createdAt'], + ], $transcriptStmt->fetchAll()); + notify_customer_chat_transcript($ticket, $transcriptMessages); + } catch (Throwable $e) { + error_log('ticket_update (Mobile App) transcript mail: ' . $e->getMessage()); + } +} +$updated = api_find_ticket($pdo, $staff, $number); +if ($updated['status'] !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]); +if ((int) ($updated['assigned_staff_id'] ?? 0) !== (int) ($ticket['assigned_staff_id'] ?? 0)) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']); +api_json(['ticket' => api_ticket_summary($updated)]); diff --git a/DeskHub-Webseite/api/staff/v1/tickets.php b/DeskHub-Webseite/api/staff/v1/tickets.php new file mode 100644 index 00000000..67e36d71 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/tickets.php @@ -0,0 +1,111 @@ + in_array($status, MOBILE_API_STATUSES, true) +)); +if ($statuses) { + $where[] = 't.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')'; + array_push($params, ...$statuses); +} + +$department = filter_var($_GET['departmentId'] ?? null, FILTER_VALIDATE_INT); +if ($department !== false && $department !== null) { + if ($departmentIds !== null && !in_array((int) $department, $departmentIds, true)) { + api_error(403, 'DEPARTMENT_FORBIDDEN', 'Auf diese Abteilung besteht kein Zugriff.'); + } + $where[] = 't.department_id = ?'; + $params[] = (int) $department; +} + +$assigned = trim((string) ($_GET['assigned'] ?? '')); +if ($assigned === 'me') { + $where[] = 't.assigned_staff_id = ?'; + $params[] = $staff['id']; +} elseif ($assigned === 'unassigned') { + $where[] = 't.assigned_staff_id IS NULL'; +} elseif ($assigned !== '' && ctype_digit($assigned)) { + $where[] = 't.assigned_staff_id = ?'; + $params[] = (int) $assigned; +} + +$priority = filter_var($_GET['priorityId'] ?? null, FILTER_VALIDATE_INT); +if ($priority !== false && $priority !== null) { + $where[] = 't.priority_id = ?'; + $params[] = (int) $priority; +} + +$tag = filter_var($_GET['tagId'] ?? null, FILTER_VALIDATE_INT); +if ($tag !== false && $tag !== null) { + $where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)'; + $params[] = (int) $tag; +} + +// SLA-Status: siehe staff/tickets.php für dieselbe Logik (Web und App teilen die +// 4-Stunden-Risiko-Schwelle, damit beide Oberflächen dasselbe "bald fällig" meinen). +$sla = trim((string) ($_GET['sla'] ?? '')); +if ($sla !== '') { + $now = db_now(); + $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600); + if ($sla === 'overdue') { + $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + $params[] = $now; + } elseif ($sla === 'at_risk') { + $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + array_push($params, $now, $riskThreshold); + } elseif ($sla === 'on_time') { + $where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')"; + $params[] = $riskThreshold; + } +} + +$search = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100); +if ($search !== '') { + $like = '%' . $search . '%'; + $where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? + OR t.requester_name LIKE ? OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))'; + array_push($params, $like, $like, $like, $like, $like); +} + +$page = max(1, (int) ($_GET['page'] ?? 1)); +$perPage = max(10, min(100, (int) ($_GET['perPage'] ?? 30))); +$offset = ($page - 1) * $perPage; +$from = ' FROM tickets t + JOIN priorities p ON p.id = t.priority_id + JOIN departments d ON d.id = t.department_id + LEFT JOIN staff s ON s.id = t.assigned_staff_id'; +$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : ''; + +$countStmt = $pdo->prepare('SELECT COUNT(*)' . $from . $whereSql); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); + +$stmt = $pdo->prepare( + 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, + d.name AS department_name, s.name AS staff_name' + . $from . $whereSql + . ' ORDER BY t.updated_at DESC, t.id DESC LIMIT ' . $perPage . ' OFFSET ' . $offset +); +$stmt->execute($params); +api_json([ + 'items' => array_map('api_ticket_summary', $stmt->fetchAll()), + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'hasMore' => ($offset + $perPage) < $total, +]); diff --git a/DeskHub-Webseite/api/staff/v1/time_entries.php b/DeskHub-Webseite/api/staff/v1/time_entries.php new file mode 100644 index 00000000..29695d28 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/time_entries.php @@ -0,0 +1,55 @@ +query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(); + +$where = ['te.logged_at BETWEEN ? AND ?']; +$params = [$filterFrom . ' 00:00:00', $filterTo . ' 23:59:59']; + +if ($filterStaffId !== '') { + $where[] = 'te.staff_id = ?'; + $params[] = (int) $filterStaffId; +} + +$stmt = $pdo->prepare( + "SELECT te.*, s.name AS staff_name, t.ticket_number, t.subject + FROM ticket_time_entries te + JOIN staff s ON s.id = te.staff_id + JOIN tickets t ON t.id = te.ticket_id + WHERE " . implode(' AND ', $where) . " + ORDER BY te.logged_at DESC + LIMIT 500" +); +$stmt->execute($params); +$entries = $stmt->fetchAll(); + +$byStaff = []; +foreach ($entries as $e) { + $byStaff[$e['staff_name']] = ($byStaff[$e['staff_name']] ?? 0) + (int) $e['minutes']; +} +$totalMinutes = array_sum(array_column($entries, 'minutes')); + +api_json([ + 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff), + 'entries' => array_map(fn(array $e) => [ + 'id' => (int) $e['id'], + 'staffName' => $e['staff_name'], + 'ticketNumber' => $e['ticket_number'], + 'subject' => $e['subject'], + 'minutes' => (int) $e['minutes'], + 'note' => $e['note'], + 'loggedAt' => $e['logged_at'], + ], $entries), + 'totalsByStaff' => array_map(fn($name, $mins) => ['staffName' => $name, 'minutes' => $mins], array_keys($byStaff), array_values($byStaff)), + 'totalMinutes' => $totalMinutes, +]); diff --git a/DeskHub-Webseite/api/staff/v1/upload_image.php b/DeskHub-Webseite/api/staff/v1/upload_image.php new file mode 100644 index 00000000..b8bd71f6 --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/upload_image.php @@ -0,0 +1,40 @@ + 5 * 1024 * 1024) { + api_error(422, 'FILE_TOO_LARGE', 'Die Datei ist zu groß (max. 5 MB).'); +} + +$result = handle_ticket_upload($file); +if (isset($result['error'])) { + api_error(422, 'UPLOAD_FAILED', $result['error']); +} + +audit_log($staff['id'], "Bild hochgeladen: {$result['stored_name']} (Mobile App)"); + +api_json([ + 'url' => base_url() . '/uploads/' . $result['stored_name'], +]); diff --git a/DeskHub-Webseite/app_downloads/.htaccess b/DeskHub-Webseite/app_downloads/.htaccess new file mode 100644 index 00000000..47e55385 --- /dev/null +++ b/DeskHub-Webseite/app_downloads/.htaccess @@ -0,0 +1,9 @@ +# Nur über staff/app_download.php (mit Anmeldung) abrufbar + + Require all denied + + + Order deny,allow + Deny from all + +Options -Indexes diff --git a/DeskHub-Webseite/app_downloads/index.html b/DeskHub-Webseite/app_downloads/index.html new file mode 100644 index 00000000..e69de29b diff --git a/DeskHub-Webseite/assets/avatars/.htaccess b/DeskHub-Webseite/assets/avatars/.htaccess new file mode 100644 index 00000000..5a928f6d --- /dev/null +++ b/DeskHub-Webseite/assets/avatars/.htaccess @@ -0,0 +1 @@ +Options -Indexes diff --git a/DeskHub-Webseite/assets/branding/.htaccess b/DeskHub-Webseite/assets/branding/.htaccess new file mode 100644 index 00000000..c08e9cf6 --- /dev/null +++ b/DeskHub-Webseite/assets/branding/.htaccess @@ -0,0 +1,5 @@ +# Verhindert die Ausführung von hochgeladenen Dateien in diesem Verzeichnis + + Require all denied + +php_flag engine off diff --git a/DeskHub-Webseite/assets/css/style.css b/DeskHub-Webseite/assets/css/style.css new file mode 100644 index 00000000..3aba9d34 --- /dev/null +++ b/DeskHub-Webseite/assets/css/style.css @@ -0,0 +1,1060 @@ +/* ========================================================== + DeskHub – Stylesheet v2 + System-Fonts only, kein CDN, DSGVO-konform. + ========================================================== */ + +:root { + --ink: #16202a; + --ink-soft: #334155; + --bg: #e3e9f1; + --surface: #ffffff; + --primary: #0f6f6a; + --primary-dk: #0b5853; + --primary-lt: #e6f4f3; + --accent: #d97706; + --accent-lt: #fef3c7; + --danger: #dc2626; + --danger-lt: #fef2f2; + --success: #16a34a; + --success-lt: #f0fdf4; + --border: #dde3ec; + --border-soft: #edf0f5; + --muted: #64748b; + --muted-lt: #94a3b8; + + --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Courier New', monospace; + --font-display: var(--font-body); + + --radius: 8px; + --radius-lg: 12px; + --shadow-sm: 0 1px 3px rgba(15,23,42,.06), 0 1px 2px rgba(15,23,42,.04); + --shadow: 0 4px 12px rgba(15,23,42,.08), 0 1px 3px rgba(15,23,42,.04); + --shadow-lg: 0 12px 28px rgba(15,23,42,.12), 0 4px 8px rgba(15,23,42,.06); +} + + +/* ── Reset ─────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + background: var(--bg); + color: var(--ink); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +body { display: flex; flex-direction: column; min-height: 100vh; } + +h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -.025em; margin-bottom: .4em; } +h2 { font-size: 1.25rem; font-weight: 700; letter-spacing: -.02em; margin-bottom: .4em; } +h3 { font-size: 1rem; font-weight: 700; letter-spacing: -.015em; margin-bottom: .3em; } +h4 { font-size: .9rem; font-weight: 600; margin-bottom: .3em; } + +a { color: var(--primary); text-decoration: none; } +a:hover { color: var(--primary-dk); text-decoration: underline; } + +/* ── App Shell ──────────────────────────────────────────── */ + +.app-shell { display: flex; min-height: 100vh; } + +/* ── Sidebar ─────────────────────────────────────────────── */ + +.sidebar { + width: 240px; + flex-shrink: 0; + background: var(--ink); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar .brand { + padding: 20px 18px 18px; + font-size: 1.25rem; + font-weight: 800; + color: #fff; + letter-spacing: -.03em; + border-bottom: 1px solid #243040; + flex-shrink: 0; +} +.sidebar .brand span { color: var(--accent); } + +/* Top-level nav links */ +.sidebar > nav { padding: 10px 0 4px; } +.sidebar > nav a { + display: flex; + align-items: center; + gap: 9px; + color: #a8b8c8; + padding: 9px 18px; + font-size: .9rem; + font-weight: 500; + border-left: 3px solid transparent; + transition: background .12s, color .12s; +} +.sidebar > nav a:hover { + background: rgba(255,255,255,.05); + color: #fff; + text-decoration: none; + border-left-color: transparent; +} +.sidebar > nav a.active { + background: rgba(15,111,106,.18); + color: #fff; + border-left-color: var(--primary); + font-weight: 600; +} + +/* Admin block */ +.nav-section-label { + padding: 14px 18px 4px; + font-size: .68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--accent); +} +.nav-section-label[role="button"] { + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} +.nav-section-label[role="button"]:hover { color: #f0a847; } +.nav-toggle-chevron { font-size: .75rem; } + +.nav-admin-block { + border-top: 1px solid #243040; + border-bottom: 1px solid #243040; + padding: 4px 0 8px; + margin: 8px 0; +} +.nav-group-label { + display: block; + padding: 10px 18px 2px; + font-size: .65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: #4e6278; +} +.nav-admin-block a { + display: block; + color: #9aabb9; + padding: 7px 18px 7px 22px; + font-size: .865rem; + font-weight: 400; + border-left: 3px solid transparent; + transition: background .1s, color .1s; +} +.nav-admin-block a:hover { + background: rgba(255,255,255,.04); + color: #dce8f0; + text-decoration: none; +} +.nav-admin-block a.active { + background: rgba(15,111,106,.14); + color: #fff; + border-left-color: var(--primary); + font-weight: 500; +} + +.sidebar-footer { + margin-top: auto; + padding: 14px 18px; + border-top: 1px solid #243040; + font-size: .78rem; + color: #4e6278; + flex-shrink: 0; +} + +/* ── Main content ───────────────────────────────────────── */ + +.main { + flex: 1; + padding: 24px 28px 60px; + max-width: 1600px; + min-width: 0; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + gap: 16px; +} + +/* ── Cards ──────────────────────────────────────────────── */ + +.card { + background: var(--surface); + border: 1px solid #c4cfdd; + border-radius: var(--radius-lg); + padding: 22px 24px; + margin-bottom: 20px; + box-shadow: 0 1px 2px rgba(15,23,42,.05), 0 6px 20px rgba(15,23,42,.10); +} + +.card h3 { + font-size: .95rem; + font-weight: 700; + color: var(--ink); + padding-bottom: 12px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border-soft); +} + +/* Settings-Grid */ +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-flow: dense; + gap: 20px; + align-items: start; +} +.settings-grid .card { margin-bottom: 0; } +.settings-grid .settings-card-wide, +.settings-grid .card-wide { grid-column: 1 / -1; } +.settings-grid .settings-card-wide form { max-width: 660px; } +@media (max-width: 900px) { .settings-grid { grid-template-columns: 1fr; } } + +/* ── Stat cards ─────────────────────────────────────────── */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 28px; +} + +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px 20px 18px; + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; +} +.stat-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--primary); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} +.stat-card.overdue::before { background: var(--danger); } +.stat-card.assigned::before { background: var(--accent); } +.stat-card.unassigned::before { background: var(--muted); } + +.stat-card .num { + font-size: 2.2rem; + font-weight: 800; + line-height: 1; + color: var(--ink); + letter-spacing: -.04em; + margin-bottom: 6px; +} +.stat-card.overdue .num { color: var(--danger); } +.stat-card .lbl { + font-size: .72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--muted); +} + +/* ── Forms ──────────────────────────────────────────────── */ + +label { + display: block; + font-size: .82rem; + font-weight: 600; + color: var(--ink-soft); + margin: 16px 0 5px; +} +label:first-child { margin-top: 0; } + +input[type=text], input[type=email], input[type=password], +input[type=search], input[type=number], input[type=date], +input[type=time], select, textarea { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius); + font-family: var(--font-body); + font-size: .9rem; + background: var(--surface); + color: var(--ink); + transition: border-color .15s, box-shadow .15s; +} +textarea { resize: vertical; min-height: 110px; } +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(15,111,106,.12); +} +input::placeholder, textarea::placeholder { color: var(--muted-lt); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--primary); + color: #fff; + border: 1.5px solid transparent; + border-radius: var(--radius); + padding: 9px 18px; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + font-family: var(--font-body); + white-space: nowrap; + transition: background .15s, box-shadow .15s, transform .1s; + text-decoration: none; +} +.btn:hover { + background: var(--primary-dk); + color: #fff; + text-decoration: none; + box-shadow: 0 4px 10px rgba(15,111,106,.25); +} +.btn:active { transform: translateY(1px); } + +.btn.secondary { + background: var(--surface); + color: var(--ink-soft); + border-color: var(--border); +} +.btn.secondary:hover { + background: var(--bg); + box-shadow: none; + color: var(--ink); +} +.btn.danger { + background: var(--danger); + border-color: transparent; +} +.btn.danger:hover { + background: #b91c1c; + box-shadow: 0 4px 10px rgba(220,38,38,.25); +} + +.btn-row { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; } +.btn-row.right { justify-content: flex-end; } + +.field-row { display: flex; gap: 14px; } +.field-row > div { flex: 1; min-width: 0; } + +/* Kundenformular: klare, gefuehrte Abschnitte statt einer langen Feldliste */ +body .ticket-create-page { + max-width: 900px; + margin: 0 auto; +} + +body .ticket-create-header { + align-items: flex-end; + margin-bottom: 30px; + padding-bottom: 24px; +} + +body .ticket-create-header h1 { + margin-bottom: 8px; + font-size: clamp(1.8rem, 3vw, 2.35rem); + letter-spacing: -.035em; +} + +body .ticket-create-header .muted { + max-width: 56ch; + line-height: 1.65; +} + +body .ticket-create-card { + padding: 0 !important; + overflow: hidden; +} + +body .ticket-create-form { + padding: 34px 38px 30px; +} + +body .ticket-form-section { + margin-bottom: 30px; + padding-bottom: 30px; + border-bottom: 1px solid var(--border-soft); +} + +body .ticket-section-label { + display: flex; + align-items: center; + gap: 11px; + margin: 0 0 21px; + color: var(--ink); + font-size: .96rem; + font-weight: 800; + letter-spacing: -.01em; +} + +body .ticket-section-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex: 0 0 28px; + border-radius: 50%; + background: var(--primary); + color: #fff; + font-size: .76rem; + line-height: 1; +} + +body .ticket-form-section label { + margin-top: 0; +} + +body .ticket-form-section .field-row label { + margin-top: 0; +} + +body .ticket-form-hint { + margin: 10px 0 0; + color: var(--muted); + font-size: .8rem; + line-height: 1.5; +} + +body .ticket-form-hint a { + font-weight: 700; +} + +body .ticket-section-optional { + color: var(--muted); + font-size: .78rem; + font-weight: 500; +} + +body .ticket-attachment-section { + margin-bottom: 22px; + padding-bottom: 0; + border-bottom: 0; +} + +body .ticket-form-actions { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding-top: 2px; +} + +body .ticket-form-actions .ticket-form-hint { + margin: 0; +} + +body .ticket-required { + color: var(--danger); +} + +body .ticket-submit { + min-width: 170px; + justify-content: center; + padding: 12px 24px; + font-size: .95rem; +} + +/* ── Badges ─────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + font-size: .72rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 100px; + color: #fff; + letter-spacing: .01em; + white-space: nowrap; +} +.badge.outline { + background: transparent; + border: 1.5px solid currentColor; +} + +/* ── Tables ─────────────────────────────────────────────── */ + +table { width: 100%; border-collapse: collapse; } +th { + text-align: left; + font-size: .73rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted); + padding: 10px 12px; + background: var(--bg); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +th:first-child { border-radius: 6px 0 0 0; } +th:last-child { border-radius: 0 6px 0 0; } + +td { + padding: 12px 12px; + border-bottom: 1px solid var(--border-soft); + font-size: .88rem; + vertical-align: middle; + color: var(--ink-soft); +} +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover td { background: #f8fafc; } +tr.is-overdue td { background: var(--danger-lt); } +tr.is-overdue:hover td { background: #fde8e8; } + +/* ── Flash messages ─────────────────────────────────────── */ + +.flash { + padding: 12px 16px; + border-radius: var(--radius); + margin-bottom: 18px; + font-size: .875rem; + font-weight: 500; + border: 1px solid; + display: flex; + align-items: flex-start; + gap: 10px; +} +.flash::before { flex-shrink: 0; margin-top: 1px; font-size: 1rem; } +.flash.error { background: var(--danger-lt); color: #991b1b; border-color: #fecaca; } +.flash.error::before { content: '✕'; } +.flash.success { background: var(--success-lt); color: #14532d; border-color: #bbf7d0; } +.flash.success::before { content: '✓'; } +.flash.info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; } +.flash.info::before { content: 'ℹ'; } + +/* ── Ticket number chip ──────────────────────────────────── */ + +.ticket-no { + font-family: var(--font-mono); + font-size: .78em; + background: var(--primary-lt); + color: var(--primary-dk); + padding: .2em .55em; + border-radius: 5px; + font-weight: 600; + letter-spacing: .02em; +} + +/* ── Filters bar ─────────────────────────────────────────── */ + +.filters { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; + align-items: flex-end; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 16px; + box-shadow: var(--shadow-sm); +} +.filters label { margin: 0 0 5px 0; font-size: .73rem; } +.filters > div { min-width: 140px; } +.filters input, .filters select { font-size: .85rem; padding: 7px 10px; } + +/* ── Ticket thread ───────────────────────────────────────── */ + +.thread-msg { + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 16px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,.06); + background: #fff; +} +.thread-msg .meta { + background: #f0f4f8; + padding: 10px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border); + gap: 12px; + flex-wrap: wrap; +} +.thread-msg.from-staff { border-left: 4px solid var(--primary); } +.thread-msg.from-staff .meta { background: var(--primary-lt); } +.thread-msg.is-note { border-left: 4px solid #f59e0b; } +.thread-msg.is-note .meta { background: #fffbeb; } +.thread-msg .body { + padding: 18px 22px; + font-size: .95rem; + line-height: 1.8; + color: #1e293b; + word-break: break-word; + overflow-wrap: break-word; +} +.thread-msg .body a { color: var(--primary); } +.thread-msg .body ul, .thread-msg .body ol { margin: 4px 0 8px 1.4em; } +.thread-msg .body p { margin: 0 0 12px; } +.thread-msg .body p:last-child { margin-bottom: 0; } +.thread-msg .body br + br { display: block; margin-top: 8px; } + + +.note-flag { + display: inline-block; + font-size: .68rem; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; + letter-spacing: .05em; + background: var(--accent-lt); + padding: 1px 6px; + border-radius: 4px; +} + +/* ── Misc ────────────────────────────────────────────────── */ + +.muted { color: var(--muted); } +.empty-state { text-align: center; padding: 52px 20px; color: var(--muted); } +.divider { border: none; border-top: 1px solid var(--border-soft); margin: 22px 0; } + +/* Auth card */ +.auth-wrap { + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + padding: 40px 20px; + flex: 1 0 auto; + background-color: #16202a; + background-size: cover; + background-position: center; + width: 100vw; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); + margin-top: -32px; + margin-bottom: 0; +} +.auth-wrap::before { + content: ''; + position: absolute; inset: 0; + pointer-events: none; +} +.auth-wrap.has-image::after { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(160deg, rgba(15,21,28,.80) 0%, rgba(15,21,28,.95) 100%); + z-index: 0; +} +.auth-wrap .auth-card { z-index: 1; } +.auth-card { + position: relative; z-index: 1; + background: #fff; + border: none; + border-radius: 20px; + padding: 44px 40px 36px; + width: 100%; + max-width: 420px; + box-shadow: 0 24px 60px rgba(0,0,0,.35), 0 4px 16px rgba(0,0,0,.2); +} +/* Markenblock in Auth-Karten (themefähig) */ +.brand-block { text-align: center; margin-bottom: 18px; } +.brand-block img { max-height: 42px; max-width: 220px; } +.brand-block-text { + font-family: Georgia, ui-serif, serif; + font-size: 1.4rem; + font-weight: 700; + color: var(--ink); +} +.brand-block-text span { color: var(--accent); } +.brand-block-lg { margin-bottom: 20px; } +.brand-block-lg img { max-height: 52px; max-width: 200px; } +.brand-block-lg .brand-block-text { font-size: 1.5rem; } + +.auth-card h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; color: #0f1923; }.auth-card .sub { color: #64748b; font-size: .875rem; margin-bottom: 26px; line-height: 1.5; } +.auth-card label { color: #374151; font-size: .83rem; font-weight: 600; } +.auth-card input[type=email], +.auth-card input[type=password], +.auth-card input[type=text] { + background: #f8fafc; border-color: #e2e8f0; color: #0f1923; +} +.auth-card input:focus { background: #fff; border-color: var(--primary); } +.auth-card a { color: var(--primary); } +.auth-card .staff-link { color: #475569; font-size: .78rem; } +.auth-card .staff-link:hover { color: var(--primary); } + +/* Login-Seite: Theme-Dateien koennen die markierten Flaechen individuell + gestalten, waehrend Layout und Abstaende zentral konsistent bleiben. */ +body .login-screen { + display: flex; + flex: 1 0 auto; + align-items: center; + justify-content: center; + padding: 56px 20px; + background-size: cover; + background-position: center; +} + +body .login-screen .login-card { + width: 100%; + max-width: 430px; +} + +body .login-screen .login-submit { + width: 100%; + justify-content: center; +} + +body .login-screen .login-forgot { + display: inline-block; + font-size: .82rem; +} + +body .login-links { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 22px; + padding-top: 18px; + border-top: 1px solid var(--border-soft); + font-size: .84rem; + text-align: center; +} + +body .login-guest-links { + color: var(--muted-lt); +} + +body .login-staff-link { + margin-top: 4px; + color: var(--muted) !important; + font-size: .78rem; +} + +/* ── Login Split-Layout ─────────────────────────────────── */ +.login-left { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 60px 48px; + position: relative; + z-index: 1; +} +.login-left-inner { + max-width: 420px; +} +.login-brand-title { + font-size: 2rem; + font-weight: 800; + color: #fff; + margin-bottom: 14px; + line-height: 1.2; +} +.login-brand-sub { + color: rgba(255,255,255,.72); + font-size: .95rem; + line-height: 1.7; + margin-bottom: 36px; +} +.login-features { + display: flex; + flex-direction: column; + gap: 18px; +} +.login-feature { + display: flex; + align-items: center; + gap: 16px; + background: rgba(255,255,255,.08); + border: 1px solid rgba(255,255,255,.12); + border-radius: 12px; + padding: 14px 18px; + backdrop-filter: blur(4px); +} +.login-feature-icon { + font-size: 1.5rem; + flex-shrink: 0; +} +.login-feature strong { + display: block; + color: #fff; + font-size: .88rem; + font-weight: 600; +} +.login-feature span { + color: rgba(255,255,255,.6); + font-size: .8rem; +} +.login-right { + width: 440px; + flex-shrink: 0; + background: #fff !important; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + position: relative; + z-index: 2; + border-radius: 0; +} +.login-form-wrap { + width: 100%; + max-width: 360px; + padding: 52px 40px; +} +@media (max-width: 700px) { + .auth-wrap { flex-direction: column; } + .login-left { display: none; } + .login-right { width: 100%; min-height: 100%; } + .login-form-wrap { padding: 40px 28px; } +} + + +/* ── Responsive ──────────────────────────────────────────── */ + +@media (max-width: 820px) { + .app-shell { flex-direction: column; } + .sidebar { width: 100%; height: auto; position: static; } + .main { padding: 20px 16px 48px; } + .field-row { flex-direction: column; gap: 0; } + .settings-grid { grid-template-columns: 1fr; } + body .ticket-create-page { max-width: none; } + body .ticket-create-header { align-items: flex-start; } + body .ticket-create-form { padding: 26px 22px 24px; } +} + +/* ================================================================ + KUNDENPORTAL – horizontale Top-Navigation + ================================================================ */ + +.site-header { background: var(--surface); border-bottom: 1px solid var(--border); } +.site-header .topbar-thin { + background: var(--ink); + color: #c4cdd6; + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 28px; + font-size: .82rem; + flex-wrap: wrap; gap: 10px; +} +.site-header .topbar-thin .welcome { color: #fff; font-weight: 600; } +.site-header .topbar-thin nav { display: flex; gap: 20px; flex-wrap: wrap; } +.site-header .topbar-thin nav a { color: #c4cdd6; } +.site-header .topbar-thin nav a:hover, +.site-header .topbar-thin nav a.active { color: #fff; text-decoration: none; } +.site-header .logo-row { padding: 16px 28px; display: flex; align-items: center; } +.site-header .logo-row img { max-height: 52px; max-width: 240px; display: block; } +.site-header .logo-row .text-brand { + font-size: 1.6rem; font-weight: 800; color: var(--ink); letter-spacing: -.03em; +} +.site-header .logo-row .text-brand span { color: var(--accent); } + +/* Hero */ +.hero-search { + position: relative; + background-color: rgba(15,21,28,.72); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + color: #fff; + padding: 68px 28px 60px; + text-align: center; + overflow: hidden; +} +.hero-search::before { + content: ''; + position: absolute; inset: 0; + pointer-events: none; +} +.hero-search.has-image::after { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(15,21,28,.75) 0%, rgba(15,21,28,.75) 70%, rgba(15,21,28,.98) 100%); + z-index: 1; +} +.hero-search .hero-inner { position: relative; z-index: 2; max-width: 620px; margin: 0 auto; } +.hero-search .eyebrow { + display: inline-block; + font-size: .72rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; + color: var(--accent); margin-bottom: 16px; +} +.hero-search h1 { font-size: 2.15rem; color: #fff; margin-bottom: 12px; } +.hero-search .sub { color: #c4cdd6; font-size: .98rem; margin-bottom: 30px; } +.hero-search .search-form { + display: flex; + background: #fff; + border-radius: 999px; + padding: 6px 6px 6px 22px; + box-shadow: 0 16px 34px rgba(0,0,0,.28); +} +.hero-search .search-form input { + flex: 1; border: none; outline: none; font-size: .95rem; + background: transparent; color: var(--ink); font-family: var(--font-body); +} +.hero-search .search-form button { + background: var(--accent); color: #fff; border: none; border-radius: 999px; + padding: 11px 24px; font-weight: 600; cursor: pointer; + font-family: var(--font-body); font-size: .88rem; white-space: nowrap; +} +.hero-search .search-form button:hover { background: #b85f08; } +.hero-search .quick-links { margin-top: 18px; font-size: .82rem; color: #9aa7b2; } +.hero-search .quick-links a { color: #d7dee4; } +.hero-search .quick-links a:hover { color: #fff; } + +.page-content { + flex: 1 0 auto; width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 32px 28px 60px; +} + +/* CTA cards */ +.cta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin: 36px 0 28px; } +.cta-card { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 32px 26px; + text-align: center; + transition: transform .15s, box-shadow .15s, border-color .15s; +} +.cta-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); } +.cta-card .icon-badge { + width: 54px; height: 54px; border-radius: 14px; + display: flex; align-items: center; justify-content: center; + margin: 0 auto 16px; +} +.cta-card .icon-badge svg { width: 26px; height: 26px; } +.cta-card.primary .icon-badge { background: rgba(15,111,106,.1); } +.cta-card.primary .icon-badge svg { stroke: var(--primary); } +.cta-card.secondary .icon-badge { background: rgba(217,119,6,.1); } +.cta-card.secondary .icon-badge svg { stroke: var(--accent); } +.cta-card h3 { margin-bottom: 8px; } +.cta-card p { color: var(--muted); font-size: .88rem; margin-bottom: 18px; min-height: 44px; } + +/* KB */ +.kb-search { max-width: 480px; margin: 0 auto 30px; } +.kb-category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; margin-bottom: 20px; +} +.kb-category-card { margin-bottom: 0; } +.kb-category-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; } +.kb-category-head .icon-badge { + width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(15,111,106,.1); +} +.kb-category-head .icon-badge svg { width: 22px; height: 22px; stroke: var(--primary); } +.kb-article-link { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 0; border-top: 1px solid var(--border); + color: var(--ink); font-size: .9rem; +} +.kb-article-link:hover { color: var(--primary); text-decoration: none; } +.kb-article-link .arrow { color: var(--muted); transition: transform .15s; } +.kb-article-link:hover .arrow { transform: translateX(3px); color: var(--primary); } +.kb-result-card:hover { box-shadow: var(--shadow); } +.popular-articles .article-row { + display: flex; align-items: baseline; gap: 16px; + padding: 14px 2px; border-bottom: 1px solid var(--border); +} +.popular-articles .article-row:last-child { border-bottom: none; } +.popular-articles .rank { + font-weight: 800; color: var(--accent); font-size: 1.1rem; width: 22px; flex-shrink: 0; +} +.popular-articles .article-title { font-weight: 600; } +.popular-articles .article-meta { color: var(--muted); font-size: .78rem; margin-top: 2px; } + +/* Steps */ +.steps-list { display: flex; flex-direction: column; gap: 16px; } +.steps-list .step { display: flex; gap: 12px; align-items: flex-start; font-size: .85rem; } +.steps-list .step-num { + flex-shrink: 0; width: 24px; height: 24px; + border-radius: 50%; background: rgba(15,111,106,.1); + color: var(--primary); font-weight: 800; font-size: .78rem; + display: flex; align-items: center; justify-content: center; +} + +footer.public-footer { + text-align: center; + background: var(--ink); + color: #4e6278; + font-size: .78rem; + padding: 18px 0; + margin-top: auto; + width: 100%; +} +body:has(.auth-wrap) footer.public-footer { margin-top: 0; flex-shrink: 0; } + +/* Auth-Seiten: Die Karte soll die volle Resthöhe füllen, statt eine leere + Fläche zwischen Karte und Footer zu lassen. */ +body:has(.auth-wrap) .page-content { + display: flex; + flex-direction: column; + max-width: none; + padding: 0; +} +body:has(.auth-wrap) .page-content > .auth-wrap { + width: 100%; + margin-left: 0; + margin-right: 0; + margin-top: 0; +} + + +/* ── Rich-Text-Editor ───────────────────────────────────────── */ +.richtext-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); } +.richtext-toolbar { + display: flex; gap: 2px; padding: 6px 8px; + background: var(--bg-soft); border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.richtext-toolbar button { + background: none; border: 1px solid transparent; border-radius: 4px; + padding: 3px 8px; font-size: .82rem; font-weight: 700; cursor: pointer; + color: var(--ink-soft); line-height: 1.4; transition: all .15s; +} +.richtext-toolbar button:hover { background: var(--border); border-color: var(--border); color: var(--ink); } +.richtext-toolbar button.active { background: var(--primary-lt); border-color: var(--primary); color: var(--primary); } +.richtext-editable { + min-height: 120px; padding: 10px 12px; outline: none; + font-size: .88rem; line-height: 1.6; color: var(--ink); + cursor: text; +} +.richtext-editable:empty::before { content: attr(data-placeholder); color: var(--muted-lt); pointer-events: none; } +.richtext-editable ul, .richtext-editable ol { padding-left: 20px; margin: 6px 0; } +.richtext-editable a { color: var(--primary); } + +@media (max-width: 800px) { + .site-header .topbar-thin { flex-direction: column; align-items: flex-start; } + .cta-grid { grid-template-columns: 1fr; } + .hero-search { padding: 48px 18px 42px; } + .hero-search h1 { font-size: 1.6rem; } + .hero-search .search-form { flex-direction: column; border-radius: 16px; padding: 14px; gap: 10px; } + .hero-search .search-form input { text-align: center; } + .page-content { padding: 24px 18px 50px; } +} + +/* ── Details/Summary (Weitere Aktionen) ─────────────────── */ +details > summary { outline: none; } +details > summary::-webkit-details-marker { display: none; } +details[open] > summary span:last-child { transform: rotate(180deg); display:inline-block; }