From 752b42245a774bf047a27235ccac7cd04c91a1d8 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Nov 2026 21:22:06 +0000 Subject: [PATCH] Upload via GUI (452 Dateien) --- DeskHub-Webseite/.htaccess | 4 + .../tools/manual/handbuch/ch17.html | 65 +++++ .../tools/manual/handbuch/ch18.html | 92 +++++++ .../tools/manual/handbuch/cover.html | 12 + .../tools/manual/handbuch/front.html | 21 ++ .../tools/manual/handbuch/manual.css | 67 +++++ .../tools/manual/install_plugins.php | 27 ++ DeskHub-Webseite/tools/manual/print_pdf.js | 36 +++ DeskHub-Webseite/tools/manual/seed_manual.php | 260 ++++++++++++++++++ 9 files changed, 584 insertions(+) create mode 100644 DeskHub-Webseite/tools/manual/handbuch/ch17.html create mode 100644 DeskHub-Webseite/tools/manual/handbuch/ch18.html create mode 100644 DeskHub-Webseite/tools/manual/handbuch/cover.html create mode 100644 DeskHub-Webseite/tools/manual/handbuch/front.html create mode 100644 DeskHub-Webseite/tools/manual/handbuch/manual.css create mode 100644 DeskHub-Webseite/tools/manual/install_plugins.php create mode 100644 DeskHub-Webseite/tools/manual/print_pdf.js create mode 100644 DeskHub-Webseite/tools/manual/seed_manual.php diff --git a/DeskHub-Webseite/.htaccess b/DeskHub-Webseite/.htaccess index fb7d8dcf..75213242 100644 --- a/DeskHub-Webseite/.htaccess +++ b/DeskHub-Webseite/.htaccess @@ -44,6 +44,10 @@ RewriteCond %{REQUEST_URI} \.(sqlite3?|db|sql|log|bak|old|orig|swp|md|sh|bat|ps1 RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC] RewriteRule ^ - [F,L] +# Übrig gebliebene alte Ordner eines Updates (…alt-JJJJMMTT-HHMMSS) nicht ausliefern +RewriteCond %{REQUEST_URI} \.alt-[0-9]{8}-[0-9]{6}(/|$) [NC] +RewriteRule ^ - [F,L] + # version.php nur intern zugänglich RewriteCond %{REQUEST_URI} ^/version\.php$ [NC] RewriteRule ^ - [F,L] diff --git a/DeskHub-Webseite/tools/manual/handbuch/ch17.html b/DeskHub-Webseite/tools/manual/handbuch/ch17.html new file mode 100644 index 00000000..646e7bfa --- /dev/null +++ b/DeskHub-Webseite/tools/manual/handbuch/ch17.html @@ -0,0 +1,65 @@ +

Sicherheit, Backup, Update und Import

+ +

Sicherheit und Datenschutz im Überblick

+ + +

Datenschutz-Werkzeuge

+ +
Dateien, die Mitarbeiter gesendet haben, und Namen in Mitarbeiter-Notizen oder Zitaten bleiben von der Löschung unberührt und sind bei Bedarf von Hand zu prüfen.
+ +

Backup

+

Adminportal → Backup (nur Administratoren).

+Die Backup-Seite mit Zeitplan und vorhandenen Sicherungen. + +
Hinter Cloudflare beendet eine Anfrage nach 100 Sekunden mit der Fehlerseite „524“; ein manuelles Backup läuft auf dem Server trotzdem zu Ende. Prüft dann die Liste, statt noch einmal zu klicken. Zuverlässiger sind der Zeitplan mit echtem Cronjob oder das Synology-NAS-Plugin.
+ +

System-Update und Datenbank-Update

+System-Update und Datenbank. + + +

Import aus osTicket

+

Wer von osTicket umzieht, übernimmt den Bestand: Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten (Kundennachrichten, Antworten, interne Notizen), Mitlesende und Anhänge. Unterstützt werden SQL-Dumps von osTicket 1.10 bis 1.18 (phpMyAdmin-Export oder mysqldump), auch mit anderem Tabellenpräfix.

+Der osTicket-Import-Assistent. +

Vorbereitung

+
    +
  1. Backup von DeskHub erstellen.
  2. +
  3. Mitarbeiter anlegen – mit denselben E-Mail-Adressen wie in osTicket, denn sie werden darüber zugeordnet. Fehlende Mitarbeiter bleiben unzugewiesen.
  4. +
  5. SQL-Dump der osTicket-Datenbank erstellen (große Dateien lieber per FTP in den DeskHub-Hauptordner legen).
  6. +
+

Durchführung

+

Einstellungen → „📥 osTicket Import“: (1) Hochladen (Dateiname im Hauptordner oder Direkt-Upload in Teilen), (2) Analysieren – DeskHub zeigt, was die Datei enthält, (3) Optionen wählen (feste Reihenfolge: Kunden → Abteilungen und Hilfethemen → Tickets → Nachrichten → Anhänge), (4) Import in kleinen Schritten mit Fortschritt und Zusammenfassung.

+ + +

Störungsanzeigen

+

DeskHub überwacht sich selbst und zeigt Probleme als Banner im Mitarbeiterbereich und in den Apps: Postfach nicht erreichbar, Abruf veraltet, Versand fehlgeschlagen, Backup defekt, Datenbank veraltet, Plugin-Updates verfügbar. Die Apps melden außerdem, wenn der Server nicht erreichbar ist, und versuchen es automatisch erneut.

diff --git a/DeskHub-Webseite/tools/manual/handbuch/ch18.html b/DeskHub-Webseite/tools/manual/handbuch/ch18.html new file mode 100644 index 00000000..6c5cc561 --- /dev/null +++ b/DeskHub-Webseite/tools/manual/handbuch/ch18.html @@ -0,0 +1,92 @@ +

Anhang

+ +

Tastenkürzel im Überblick

+ + + + + + + + + + + + + + + + +
WoTasteWirkung
Ticketlistej / knächstes / vorheriges Ticket wählen
TicketlisteEnter oder oTicket öffnen
Ticketlisteröffnen und antworten
Ticketlistexankreuzen
Ticketliste/Suche
Ticketlistenneues Ticket
Ticketj / kdurch die Nachrichten blättern
TicketrAntwort schreiben
Ticketuzurück zur Liste
AntwortfeldStrg + EnterAntwort senden
AntwortfeldStrg + VScreenshot oder kopiertes Bild einfügen
BildvorschauPfeiltasten / Escblättern / schließen
überall?Kürzel-Übersicht (mit Abschaltmöglichkeit)
Chrome-ErweiterungAlt+Shift+N / DNeues Ticket / Erweiterung öffnen
+ +

Rollen und typische Rechte

+ + + + + + + +
RolleDarf
KundeTickets anlegen und verfolgen, antworten, bewerten; kein Zugang zum Mitarbeiterbereich
Mitarbeiter (Agent)Tickets seiner Abteilungen lesen, beantworten, notieren; Status, Zuweisung, Wiedervorlage; Chat, Aufgaben, eigene Berichte
Eigene Rolle (z. B. Teamleitung)zusätzlich laut Rechte: zuweisen, schließen, löschen, Tickets übertragen, Eskalationsregeln, digitalen Assistenten verwalten …
Co-AdminRolle mit freigeschalteten Adminportal-Bereichen; nie Backup, System-Update, Ticket-Regeln, Plugins, API-Schlüssel, Webhooks
AdministratorAlles; Ticket-Sperren übernehmen; Konten sichern und abmelden
+ +

Wer bekommt welche Benachrichtigung?

+ + + + + + + + + + + +
EreignisEmpfänger
Neues TicketAlle Administratoren, Mitarbeiter der Abteilung und Mitarbeiter mit Benachrichtigung für diese Abteilung
KundenantwortDer zugewiesene Mitarbeiter; ohne Zuweisung die Abteilung; Beobachter
ZuweisungDer neue Bearbeiter (nicht, wer sich selbst zuweist)
ErwähnungDer Erwähnte, wenn er Zugriff auf die Abteilung hat
SLA überschrittenStandard: nur Administratoren per Mail (bei Urlaub die Vertretung); alternativ Bearbeiter/Abteilung per Mail und App-Push
Ticket wartet auf AntwortDer zugewiesene Bearbeiter (Push, optional Mail)
Wiedervorlage fälligDer Mitarbeiter, der das Ticket zurückgestellt hat
Anmeldung von neuem Gerät, SperreDer Kontoinhaber (und bei Sperre die Administratoren)
Postfach-Störung, defektes Backup, DienstausfallDie Administratoren
+

Im Urlaubsmodus bekommt statt des Mitarbeiters die Vertretung diese Meldungen (Sicherheitsmails und Systemmeldungen an Administratoren ausgenommen).

+ +

Glossar

+ + + + + + + + + + + + + + + + + +
BegriffBedeutung
TicketEine Anfrage mit Nummer, Verlauf, Status, Priorität und Abteilung
Interne NotizNachricht im Ticket, die nur das Team sieht
AbteilungThemenbereich, dem ein Ticket genau einmal zugeordnet ist; Grenze der Sichtbarkeit
HilfethemaAuswahl für Kunden beim Anlegen; legt Abteilung, Priorität, ggf. SLA und Formularfelder fest
SLAService-Level-Vereinbarung: Fristen für erste Antwort und Lösung, gerechnet in Geschäftszeiten
WiedervorlageTicket „auf später legen“; es kommt zum gewählten Zeitpunkt zurück
BeobachterMitarbeiter, der ein Ticket verfolgt, ohne Bearbeiter zu sein
Cc / MitleserExterne E-Mail-Adresse, die öffentliche Antworten in Kopie bekommt
Co-AdminMitarbeiter mit Zugriff auf einzelne Adminportal-Bereiche
CSATZufriedenheitsabfrage mit fünf Sternen
Cron / Pseudo-CronZeitgesteuerte Hintergrundaufgaben (echter Server-Cronjob oder eingebauter Ersatz)
WebhookAutomatische Meldung von DeskHub an ein anderes System bei einem Ereignis
PluginNachrüstbare Zusatzfunktion als ZIP-Paket
2FA / TOTPZwei-Faktor-Anmeldung mit Einmalcode aus einer Authenticator-App
PasskeyAnmeldung ohne Passwort per Fingerabdruck, Gesicht oder Geräte-PIN (WebAuthn); ersetzt Passwort und 2FA-Code
+ +

Wenn etwas nicht funktioniert

+ + + + + + + + + + + + + +
ProblemWas zu tun ist
Eine automatische Mail kommt nicht anAdminportal → Admin-Protokoll öffnen (genaue Fehlermeldung des Anbieters); Einstellungen → E-Mail → „Test-Mail senden“; einzelne Absenderadresse unter E-Mail-Adressen testen; Mail-Warteschlange ansehen
E-Mails werden nicht zu TicketsPostfächer (Abruf) → „Testen“ zeigt die Meldung des Mail-Servers; „E-Mails jetzt abrufen“ in der Ticketliste ausprobieren
Rotes oder gelbes Banner obenStörungsmeldung der Überwachung (Postfach, Abruf, Versand); Details unter E-Mail-Adressen und Postfächer
„Datenbank veraltet“System-Update → „Datenbank aktualisieren“
Ein Plugin macht ProblemeUnter Plugins deaktivieren; lässt sich die Seite nicht öffnen, Datei plugins/.disabled anlegen
„Mit Passkey anmelden“ fehlt oder schlägt fehlWebsite-URL muss mit https:// beginnen, die aufgerufene Adresse muss dazu passen (eine Subdomain davon ist erlaubt); im Profil steht unter „Passkeys“, für welche Domain sie gelten; im Adminportal prüfen, ob „Passkeys erlauben“ an ist; Passkey im Profil prüfen; sonst mit Passwort anmelden und neu einrichten oder einen Administrator bitten, die Passkeys zu entfernen
Konto gesperrt (zu viele Fehlversuche)15 Minuten warten, „Passwort vergessen“ nutzen oder einen Administrator bitten, die Sperre aufzuheben
Die App meldet „Ein gültiges Bearer-Token ist erforderlich“Der Webserver reicht den Anmelde-Header nicht weiter (typisch Apache mit PHP-FPM): neue .htaccess hochladen bzw. auf nginx fastcgi_param HTTP_AUTHORIZATION $http_authorization; setzen
Die App zeigt „Keine Verbindung“Erreichbarkeit der Seite im Browser prüfen; sobald der Server antwortet, verschwindet das Banner von selbst
Zähler in Chrome-Erweiterung/Windows-Programm stimmt nichtServer und Programme aktualisieren (ab Server 1.0.69); der Zähler zeigt nur Tickets, die jetzt Aufmerksamkeit brauchen
Nach einem Update sieht alles unverändert ausBrowser-Cache leeren (Strg + F5)
+ +

Versionsstand dieses Handbuchs

+ + + + + + + +
DeskHub-Server1.1.0
Windows-Programm2.12.3
Android-App3.5.3
Chrome-Erweiterung2.1.1
WordPress-Plugin1.3.0
Minecraft-Plugin1.2.0
+

Dieses Handbuch beschreibt den Funktionsumfang dieser Versionen. Alle Namen, Adressen und Zahlen in den Abbildungen sind erfunden; example-Domains sind reservierte Beispieladressen.

diff --git a/DeskHub-Webseite/tools/manual/handbuch/cover.html b/DeskHub-Webseite/tools/manual/handbuch/cover.html new file mode 100644 index 00000000..329e2e14 --- /dev/null +++ b/DeskHub-Webseite/tools/manual/handbuch/cover.html @@ -0,0 +1,12 @@ +
+
+
D
+

DeskHub
Handbuch

+
+
Das vollständige Benutzer- und Administrationshandbuch für das selbst gehostete Helpdesk-System: Kundenportal, Mitarbeiterbereich, Adminportal, Apps und Plugins.
+
+ Stand der beschriebenen Versionen
+ {{VERSION}}

+ Alle Abbildungen zeigen eine Demo-Installation mit erfundenen Beispieldaten. +
+
diff --git a/DeskHub-Webseite/tools/manual/handbuch/front.html b/DeskHub-Webseite/tools/manual/handbuch/front.html new file mode 100644 index 00000000..6df5ad0d --- /dev/null +++ b/DeskHub-Webseite/tools/manual/handbuch/front.html @@ -0,0 +1,21 @@ +
+

Über dieses Handbuch

+

Dieses Handbuch beschreibt jede Funktion von DeskHub: von der Installation über das Kundenportal und die tägliche Arbeit der Mitarbeiter bis zu allen Bereichen des Adminportals, den mitgelieferten Plugins und den Apps. Es ist so aufgebaut, dass Einsteiger von vorn nach hinten lesen können; erfahrene Nutzer springen über das Inhaltsverzeichnis (die Einträge sind anklickbar) direkt zum Thema.

+

An wen richtet sich welches Kapitel?

+ + + + + +
RolleKapitel
KundenKapitel 3 (Kundenportal)
MitarbeiterKapitel 4 bis 9 (Mitarbeiterbereich, Tickets, Chat, Berichte, Profil) und 14 (Apps)
AdministratorenKapitel 2 sowie 10 bis 13 und 15 bis 17 (Installation, Adminportal, Einstellungen, Plugins, Integrationen, Sicherheit, Backup, Update); der Anhang (Kapitel 18) enthält Kürzel, Glossar und Fehlerhilfe für alle
+

Schreibweisen

+ +
Alle Daten in den Abbildungen sind erfunden. Namen, E-Mail-Adressen, Tickets, Firmen und Zahlen stammen aus einer eigens angelegten Demo-Installation. Die Domains demo.example und kunde.example sind reservierte Beispieladressen und gehören niemandem. Es kommen keine echten Kunden-, Mitarbeiter- oder Zugangsdaten vor.
+
Was dieses Handbuch nicht zeigt: Von der Android-App und der Chrome-Erweiterung gibt es hier keine Bildschirmfotos, weil für sie keine Demo-Umgebung zur Verfügung stand. Ihre Funktionen sind im Text vollständig beschrieben. Das Windows-Programm ist mit Bildern aus einer Test-Attrappe mit Beispieldaten dargestellt (es hat dort keine Verbindung zu einem Server, daher einzelne Hinweise wie „Mock: … nicht vorhanden“ in den Bildern; diese gehören nicht zum Programm).
+

Aufbau der Weboberfläche

+

DeskHub besteht aus drei Bereichen, die ihr in den folgenden Kapiteln einzeln kennenlernt: dem Kundenportal (Adresse /), dem Mitarbeiterbereich samt Adminportal (Adresse /staff/) und den Apps (Windows, Android, Chrome-Erweiterung sowie WordPress- und Minecraft-Plugin).

+
diff --git a/DeskHub-Webseite/tools/manual/handbuch/manual.css b/DeskHub-Webseite/tools/manual/handbuch/manual.css new file mode 100644 index 00000000..fe24d05d --- /dev/null +++ b/DeskHub-Webseite/tools/manual/handbuch/manual.css @@ -0,0 +1,67 @@ +@page { size: A4; margin: 18mm 18mm 20mm 18mm; } +@page :first { margin: 0; } +:root { --teal: #0f6f69; --teal-d: #0a4d49; --ink: #1f2937; --mute: #6b7280; --line: #d9e2e1; --soft: #f1f7f6; } +* { box-sizing: border-box; } +html { font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; color: var(--ink); font-size: 10pt; line-height: 1.5; } +body { margin: 0; } +/* Deckblatt */ +.cover { height: 297mm; width: 210mm; page-break-after: always; position: relative; color: #fff; overflow: hidden; + background: linear-gradient(155deg, #0a4d49 0%, #0f6f69 55%, #1d9a8f 100%); } +.cover .shape1 { position: absolute; right: -60mm; top: -40mm; width: 170mm; height: 170mm; border-radius: 50%; background: rgba(255,255,255,.07); } +.cover .shape2 { position: absolute; left: -50mm; bottom: -60mm; width: 150mm; height: 150mm; border-radius: 50%; background: rgba(255,255,255,.06); } +.cover .mark { position: absolute; left: 25mm; top: 30mm; width: 26mm; height: 26mm; border-radius: 6mm; background: #fff; color: var(--teal); font-weight: 800; font-size: 20mm; line-height: 26mm; text-align: center; } +.cover h1 { position: absolute; left: 25mm; top: 78mm; margin: 0; font-size: 46pt; letter-spacing: -1px; line-height: 1.05; border: 0; color: #fff; } +.cover .sub { position: absolute; left: 25mm; top: 134mm; width: 150mm; font-size: 17pt; font-weight: 300; line-height: 1.35; } +.cover .rule { position: absolute; left: 25mm; top: 126mm; width: 40mm; height: 1.4mm; background: #fff; opacity: .8; } +.cover .meta { position: absolute; left: 25mm; bottom: 28mm; width: 160mm; font-size: 10pt; line-height: 1.6; opacity: .95; } +.cover .meta b { font-weight: 600; } +/* Vorspann */ +.front { page-break-after: always; } +.front h1.plain, .toc h1.plain { border: 0; margin-top: 0; } +/* Überschriften */ +h1 { font-size: 24pt; color: var(--teal-d); margin: 0 0 6mm; padding-bottom: 3mm; border-bottom: 2px solid var(--teal); page-break-before: always; line-height: 1.15; break-after: avoid; } +h1.plain { page-break-before: auto; } +.front h1, .toc h1 { page-break-before: auto; } +h2 { font-size: 15pt; color: var(--teal); margin: 8mm 0 3mm; break-after: avoid; line-height: 1.25; } +h3 { font-size: 11pt; color: var(--ink); margin: 5mm 0 1.5mm; break-after: avoid; } +.num { color: #8fb9b5; font-weight: 400; margin-right: 2mm; } +p { margin: 0 0 2.5mm; orphans: 3; widows: 3; } +ul, ol { margin: 0 0 3mm; padding-left: 6mm; } +li { margin-bottom: 1mm; } +code { font-family: Consolas, "Courier New", monospace; font-size: 9pt; background: #eef3f2; padding: 0 1mm; border-radius: 1mm; } +kbd { font-family: Consolas, monospace; font-size: 8.5pt; background: #fff; border: 1px solid #b7c6c4; border-bottom-width: 2px; border-radius: 1.2mm; padding: 0 1.4mm; white-space: nowrap; } +table { border-collapse: collapse; width: 100%; margin: 2mm 0 4mm; font-size: 9pt; break-inside: auto; } +th { background: var(--teal); color: #fff; text-align: left; padding: 1.6mm 2.2mm; font-weight: 600; } +td { padding: 1.5mm 2.2mm; border-bottom: 1px solid var(--line); vertical-align: top; } +tr { break-inside: avoid; } +tr:nth-child(even) td { background: #f7fbfa; } +/* Bilder */ +figure { margin: 3mm 0 5mm; padding: 0; text-align: center; break-inside: avoid; } +figure img { max-width: 100%; max-height: 205mm; width: auto; height: auto; border: 1px solid #c8d4d3; border-radius: 1.5mm; box-shadow: 0 1px 3px rgba(0,0,0,.12); } +figure.narrow img { max-height: 120mm; } +figcaption { font-size: 8.5pt; color: var(--mute); margin-top: 1.5mm; } +/* Hinweisboxen */ +.tip, .warn, .note { margin: 3mm 0 4mm; padding: 2.4mm 3.5mm; border-left: 3mm solid; border-radius: 0 1.5mm 1.5mm 0; font-size: 9.5pt; break-inside: avoid; } +.tip { background: #eaf6ef; border-color: #2f9e6b; } +.warn { background: #fff4e5; border-color: #e08a00; } +.note { background: #eaf2fb; border-color: #3b82c4; } +.tip::before, .warn::before, .note::before { font-weight: 700; display: block; margin-bottom: .5mm; } +.tip::before { content: "Tipp"; color: #1f7a50; } +.warn::before { content: "Achtung"; color: #b36b00; } +.note::before { content: "Hinweis"; color: #2a6aa3; } +.steps { counter-reset: s; list-style: none; padding-left: 0; } +.steps li { counter-increment: s; padding-left: 8mm; position: relative; margin-bottom: 1.6mm; } +.steps li::before { content: counter(s); position: absolute; left: 0; top: 0; width: 5.4mm; height: 5.4mm; border-radius: 50%; background: var(--teal); color: #fff; font-size: 8pt; text-align: center; line-height: 5.4mm; font-weight: 600; } +/* Inhaltsverzeichnis */ +.toc { page-break-after: always; } +.toc a { display: flex; align-items: baseline; color: inherit; text-decoration: none; } +.toc a.t1 { font-weight: 600; margin-top: 2.2mm; color: var(--teal-d); font-size: 10.5pt; } +.toc a.t2 { padding-left: 8mm; font-size: 9pt; line-height: 1.35; } +.toc .tn { width: 9mm; flex: none; } +.toc a.t2 .tn { width: 10mm; color: var(--mute); } +.toc .tt { flex: none; max-width: 140mm; } +.toc .tl { flex: 1; border-bottom: 1px dotted #aab8b6; margin: 0 1.5mm; position: relative; top: -1mm; } +.toc .tp { flex: none; min-width: 7mm; text-align: right; } +.facts td:first-child { width: 42mm; font-weight: 600; } +.small { font-size: 8.5pt; color: var(--mute); } +.cols2 { column-count: 2; column-gap: 8mm; } diff --git a/DeskHub-Webseite/tools/manual/install_plugins.php b/DeskHub-Webseite/tools/manual/install_plugins.php new file mode 100644 index 00000000..09b512ab --- /dev/null +++ b/DeskHub-Webseite/tools/manual/install_plugins.php @@ -0,0 +1,27 @@ + + */ +$tmp = rtrim(str_replace('\\', '/', $argv[1] ?? ''), '/'); +$zips = rtrim(str_replace('\\', '/', $argv[2] ?? ''), '/'); +if (!is_file("$tmp/includes/bootstrap.php") || !is_dir($zips)) { fwrite(STDERR, "Aufruf: install_plugins.php \n"); exit(1); } +putenv('DESKHUB_CATALOG_DEFAULTS=' . dirname(__DIR__, 2) . '/tests/fixtures/no-catalog.php'); +chdir($tmp); +date_default_timezone_set('Europe/Berlin'); +require_once "$tmp/includes/bootstrap.php"; +$pdo = get_db(); +$want = ['tagesbericht', 'schichtuebergabe', 'rundmail', 'spamfilter', 'virenscan', 'ueberwachung', 'datenschutz', 'vertretung', 'community-bot', 'authentik', 'nas-backup', 'virustotal', 'cache-manager', 'code-issue-sync', 'cloudflare-protection', 'lang-en']; +foreach ($want as $slug) { + $best = null; + foreach (glob("$zips/{$slug}-*.zip") ?: [] as $z) if ($best === null || version_compare(preg_replace('/^.*-(\d+\.\d+\.\d+)\.zip$/', '$1', basename($z)), preg_replace('/^.*-(\d+\.\d+\.\d+)\.zip$/', '$1', basename($best))) > 0) $best = $z; + if (!$best) { echo "übersprungen (kein ZIP): $slug\n"; continue; } + $r = plugin_install_zip($pdo, $best); + if (empty($r['ok'])) { echo "FEHLER $slug: " . json_encode($r, JSON_UNESCAPED_UNICODE) . "\n"; continue; } + $zip = new ZipArchive(); + $zip->open($best); + $perms = []; + for ($i = 0; $i < $zip->numFiles; $i++) if (preg_match('#(^|/)plugin\.json$#', $zip->getNameIndex($i))) { $perms = (json_decode($zip->getFromIndex($i), true)['permissions'] ?? []); break; } + $a = plugin_set_active($pdo, $r['slug'], true, $perms); + echo ($a['ok'] ? 'aktiv ' : 'inaktiv ') . $r['slug'] . (empty($a['ok']) ? ' – ' . json_encode($a, JSON_UNESCAPED_UNICODE) : '') . "\n"; +} diff --git a/DeskHub-Webseite/tools/manual/print_pdf.js b/DeskHub-Webseite/tools/manual/print_pdf.js new file mode 100644 index 00000000..06137ac3 --- /dev/null +++ b/DeskHub-Webseite/tools/manual/print_pdf.js @@ -0,0 +1,36 @@ +'use strict'; +/** + * tools/manual/print_pdf.js: wandelt handbuch.html in eine A4-PDF um (mit Kopf-/Fußzeile, Lesezeichen aus den Überschriften). + * electron tools/manual/print_pdf.js [cover.html cover.pdf] + */ +const { app, BrowserWindow } = require('electron'); +const fs = require('fs'); +const path = require('path'); + +const src = path.resolve(process.argv[2]); +const dst = path.resolve(process.argv[3]); + +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: false, width: 1000, height: 1400, webPreferences: { offscreen: false } }); + await win.loadFile(src); + await new Promise((r) => setTimeout(r, 1500)); + const pdf = await win.webContents.printToPDF({ + pageSize: 'A4', + printBackground: true, + preferCSSPageSize: true, + displayHeaderFooter: true, + headerTemplate: '
', + footerTemplate: '
DeskHub Handbuch · alle Beispieldaten sind erfundenSeite von
', + generateDocumentOutline: true, + generateTaggedPDF: true, + }); + fs.writeFileSync(dst, pdf); + if (process.argv[4]) { + const w2 = new BrowserWindow({ show: false, width: 900, height: 1200 }); + await w2.loadFile(path.resolve(process.argv[4])); + await new Promise((r) => setTimeout(r, 500)); + fs.writeFileSync(path.resolve(process.argv[5]), await w2.webContents.printToPDF({ pageSize: 'A4', printBackground: true, preferCSSPageSize: true })); + } + console.log('PDF geschrieben: ' + dst + ' (' + Math.round(pdf.length / 1024) + ' KB)'); + app.quit(); +}); diff --git a/DeskHub-Webseite/tools/manual/seed_manual.php b/DeskHub-Webseite/tools/manual/seed_manual.php new file mode 100644 index 00000000..fb9e560c --- /dev/null +++ b/DeskHub-Webseite/tools/manual/seed_manual.php @@ -0,0 +1,260 @@ + + * Zugang danach: lena@demo.example / Demo-Passwort-1234 (Administratorin), kunde: erika.muster@kunde.example / Demo-Passwort-1234 + */ +$tmp = rtrim(str_replace('\\', '/', $argv[1] ?? ''), '/'); +if ($tmp === '' || !is_file("$tmp/database/deskhub.sqlite")) { fwrite(STDERR, "Demo-Ordner fehlt. Erst tools/demo-video/seed_demo.php ausführen.\n"); exit(1); } +date_default_timezone_set('Europe/Berlin'); +$p = new PDO("sqlite:$tmp/database/deskhub.sqlite"); +$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$ts = fn(string $rel) => date('Y-m-d H:i:s', strtotime($rel)); +$warn = []; +$try = function (string $what, callable $fn) use (&$warn) { try { $fn(); } catch (Throwable $e) { $warn[] = $what . ': ' . $e->getMessage(); } }; +$one = fn(string $sql, array $args = []) => (function () use ($p, $sql, $args) { $s = $p->prepare($sql); $s->execute($args); return $s->fetchColumn(); })(); +$set = $p->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)'); +$id = fn(string $table, string $col, string $val) => (int) $one("SELECT id FROM {$table} WHERE {$col} = ?", [$val]); +$H = password_hash('Demo-Passwort-1234', PASSWORD_DEFAULT); + +// ── Abteilungen, Prioritäten, SLA ─────────────────────────────────────────── +$try('Abteilungen', function () use ($p, $ts) { + $p->exec("UPDATE departments SET email = 'support@demo.example', notes = 'Erste Anlaufstelle für alle Anfragen' WHERE name = 'Allgemeiner Support'"); + $p->exec("UPDATE departments SET email = 'buchhaltung@demo.example', notes = 'Rechnungen, Zahlungen, Mahnungen' WHERE name = 'Buchhaltung'"); + $p->exec("UPDATE departments SET email = 'technik@demo.example', notes = 'Störungen und technische Fragen' WHERE name = 'Technik'"); +}); +$try('SLA-Pläne', function () use ($p, $ts, $id) { + $p->exec("INSERT INTO sla_plans (name, response_hours, resolution_hours, is_active, notes, created_at, updated_at) VALUES ('Standard', 8, 48, 1, 'Für normale Anfragen', '{$ts('-60 days')}', '{$ts('-60 days')}'), ('Premium', 1, 8, 1, 'Für Kunden mit Servicevertrag', '{$ts('-60 days')}', '{$ts('-10 days')}')"); + $p->exec("UPDATE departments SET sla_plan_id = " . $id('sla_plans', 'name', 'Standard') . " WHERE name = 'Allgemeiner Support'"); +}); +$dept = fn(string $n) => $id('departments', 'name', $n); +$prio = fn(string $n) => $id('priorities', 'name', $n); +$staffId = fn(string $n) => $id('staff', 'name', $n); +$lena = $staffId('Lena Hartmann'); +$tim = $staffId('Tim Roth'); + +// ── Hilfethemen, Felder, Listen ───────────────────────────────────────────── +$try('Hilfethemen', function () use ($p, $ts, $dept, $prio) { + $ins = $p->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, sort_order, is_public, notes, created_at, updated_at) VALUES (?,?,?,1,?,1,?,?,?)'); + foreach ([['Rechnung und Zahlung', 'Buchhaltung', 'Hoch', 1, 'Fragen zu Rechnungen und Zahlungen'], ['Technische Störung', 'Technik', 'Dringend', 2, 'Etwas funktioniert nicht'], ['Allgemeine Frage', 'Allgemeiner Support', 'Normal', 3, 'Alles andere'], ['Kündigung und Vertrag', 'Allgemeiner Support', 'Normal', 4, 'Verträge ändern oder beenden']] as [$n, $d, $pr, $so, $no]) + $ins->execute([$n, $dept($d), $prio($pr), $so, $no, $ts('-60 days'), $ts('-60 days')]); +}); +$try('Listen und Felder', function () use ($p, $ts, $id) { + $p->exec("INSERT INTO custom_lists (name, sort_order, created_at) VALUES ('Betriebssystem', 1, '{$ts('-50 days')}')"); + $lid = (int) $p->lastInsertId(); + foreach (['Windows', 'macOS', 'Linux', 'Android', 'iOS'] as $i => $v) $p->prepare('INSERT INTO custom_list_items (list_id, value, sort_order) VALUES (?,?,?)')->execute([$lid, $v, $i]); + $ht = $id('help_topics', 'name', 'Rechnung und Zahlung'); + $ht2 = $id('help_topics', 'name', 'Technische Störung'); + $p->prepare("INSERT INTO custom_fields (label, field_type, is_required, is_active, sort_order, created_at, help_topic_id, help_text) VALUES ('Rechnungsnummer', 'text', 1, 1, 1, ?, ?, 'Zu finden oben auf der Rechnung')")->execute([$ts('-50 days'), $ht]); + $p->prepare("INSERT INTO custom_fields (label, field_type, list_id, is_required, is_active, sort_order, created_at, help_topic_id) VALUES ('Betriebssystem', 'list', ?, 0, 1, 2, ?, ?)")->execute([$lid, $ts('-50 days'), $ht2]); +}); + +// ── Vorlagen, Teams, Rollen ───────────────────────────────────────────────── +$try('Vorlagen', function () use ($p, $ts, $dept) { + $ins = $p->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)'); + foreach ([ + ['Danke für deine Nachricht', '

Hallo {{kundenname}},

vielen Dank für deine Nachricht. Wir kümmern uns darum und melden uns schnellstmöglich.

{{signatur}}

', null], + ['Rechnung erneut senden', '

Hallo {{kundenname}},

wir haben dir die Rechnung noch einmal als PDF angehängt. Sie steht außerdem in deinem Kundenbereich unter „Rechnungen“.

', 'Buchhaltung'], + ['Rückfrage zur Störung', '

Hallo {{kundenname}},

damit wir die Störung nachstellen können: Welches Gerät und welche Version nutzt du? Hilft ein Neustart?

', 'Technik'], + ['Ticket gelöst', '

Hallo {{kundenname}},

wir haben dein Anliegen gelöst. Falls noch etwas offen ist, antworte einfach auf diese Nachricht.

', null], + ['Kündigung bestätigt', '

Hallo {{kundenname}},

wir bestätigen deine Kündigung zum Ende der Laufzeit. Schade, dass du gehst!

', 'Allgemeiner Support'], + ] as [$t, $b, $d]) $ins->execute([$t, $b, $d ? $dept($d) : null, $ts('-40 days')]); +}); +$try('Rollen', function () use ($p, $ts) { + $p->prepare('INSERT INTO roles (name, permissions, created_at) VALUES (?,?,?)')->execute(['Teamleitung', json_encode(['reply_ticket' => true, 'close_ticket' => true, 'post_note' => true, 'assign_ticket' => true, 'transfer_ticket' => true, 'manage_canned' => true, 'view_reports' => true, 'manage_teams' => true]), $ts('-45 days')]); + $p->prepare('INSERT INTO roles (name, permissions, created_at) VALUES (?,?,?)')->execute(['Nur lesen', json_encode(['view_reports' => true]), $ts('-45 days')]); +}); +$try('Teams', function () use ($p, $ts, $staffId) { + $p->prepare('INSERT INTO teams (name, lead_id, notes, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['Support-Team', $staffId('Lena Hartmann'), 'Tagesgeschäft und Kundenanfragen', $ts('-45 days')]); + $t1 = (int) $p->lastInsertId(); + $p->prepare('INSERT INTO teams (name, lead_id, notes, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['Technik-Team', $staffId('Mira Schulz'), 'Störungen und Entwicklung', $ts('-45 days')]); + $t2 = (int) $p->lastInsertId(); + foreach ([[$t1, 'Lena Hartmann'], [$t1, 'Tim Roth'], [$t2, 'Mira Schulz'], [$t2, 'Tim Roth']] as [$t, $n]) $p->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)')->execute([$t, $staffId($n)]); +}); + +// ── Kunden, Organisationen ────────────────────────────────────────────────── +$try('Kunden', function () use ($p, $ts, $H, $lena) { + $p->exec("INSERT INTO organizations (name, notes, created_at) VALUES ('Beispiel Bau GmbH', 'Rahmenvertrag, Premium-Support', '{$ts('-80 days')}'), ('Musterhaus AG', 'Pilotkunde', '{$ts('-70 days')}')"); + $o1 = (int) $p->query("SELECT id FROM organizations WHERE name = 'Beispiel Bau GmbH'")->fetchColumn(); + $o2 = (int) $p->query("SELECT id FROM organizations WHERE name = 'Musterhaus AG'")->fetchColumn(); + $ins = $p->prepare('INSERT INTO users (name, email, password_hash, phone, organization_id, created_at) VALUES (?,?,?,?,?,?)'); + foreach ([['Erika Muster', 'erika.muster@kunde.example', '0123 456789', $o1, '-90 days'], ['Jonas Weber', 'jonas.weber@kunde.example', null, $o1, '-75 days'], ['Sophie Klein', 'sophie.klein@kunde.example', null, $o2, '-60 days'], ['Markus Lehmann', 'markus.lehmann@kunde.example', '0171 0000000', null, '-30 days'], ['Lea Wagner', 'lea.wagner@kunde.example', null, null, '-20 days'], ['Dirk Neumann', 'dirk.neumann@kunde.example', null, $o2, '-10 days']] as [$n, $e, $ph, $o, $c]) + $ins->execute([$n, $e, $H, $ph, $o, $ts($c)]); + $uid = (int) $p->query("SELECT id FROM users WHERE email = 'erika.muster@kunde.example'")->fetchColumn(); + $p->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')->execute([$uid, $lena, 'Wünscht Rückrufe nur vormittags. Ansprechpartnerin für die Buchhaltung.', $ts('-20 days')]); +}); + +// ── Weitere Tickets ───────────────────────────────────────────────────────── +$try('Tickets', function () use ($p, $ts, $dept, $prio, $staffId) { + $tk = $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at, resolution_due_at, first_response_due_at, first_responded_at, resolved_at, closed_at, is_overdue, user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); + $th = $p->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)'); + $uid = fn(string $e) => (int) $p->query("SELECT id FROM users WHERE email = " . $p->quote($e))->fetchColumn() ?: null; + foreach ([ + ['260107', 'Zahlung doppelt abgebucht', 'Erika Muster', 'Buchhaltung', 'Hoch', 'open', 'email', 'Lena Hartmann', '-4 hours', '+4 hours', null, [['customer', null, "Hallo,\nmeine Rechnung 4711 wurde zweimal abgebucht. Bitte um Erstattung.", '-4 hours']]], + ['260108', 'Webseite lädt sehr langsam', 'Jonas Weber', 'Technik', 'Dringend', 'open', 'web', 'Mira Schulz', '-9 hours', '-1 hours', null, [['customer', null, 'Seit heute Morgen braucht jede Seite über 10 Sekunden.', '-9 hours'], ['staff', 'Mira Schulz', 'Wir prüfen das gerade. Danke für den Hinweis!', '-8 hours']]], + ['260109', 'Wie ändere ich meine Lieferadresse?', 'Sophie Klein', 'Allgemeiner Support', 'Normal', 'pending', 'chat', 'Tim Roth', '-1 days', '+30 hours', null, [['customer', null, 'Ich ziehe um. Wo ändere ich die Adresse?', '-1 days'], ['staff', 'Tim Roth', 'Im Kundenbereich unter „Profil“. Klappt das bei dir?', '-23 hours']]], + ['260110', 'Angebot für 50 Lizenzen', 'Lea Wagner', 'Allgemeiner Support', 'Normal', 'open', 'phone', null, '-3 hours', '+45 hours', null, [['customer', null, 'Telefonische Anfrage: Angebot für 50 Lizenzen, Rückruf gewünscht.', '-3 hours']]], + ['260111', 'Rechnung fehlt (Monat Februar)', 'Dirk Neumann', 'Buchhaltung', 'Normal', 'on_hold', 'api', 'Lena Hartmann', '-2 days', '+3 days', null, [['customer', null, 'Die Februar-Rechnung fehlt im Kundenbereich.', '-2 days'], ['staff', 'Lena Hartmann', 'Wir klären das mit dem Rechnungsprogramm und melden uns.', '-47 hours']]], + ['259905', 'Zugang gesperrt nach Passwortwechsel', 'Markus Lehmann', 'Allgemeiner Support', 'Hoch', 'resolved', 'web', 'Tim Roth', '-8 days', null, '-7 days', [['customer', null, 'Nach dem Passwortwechsel komme ich nicht mehr rein.', '-8 days'], ['staff', 'Tim Roth', 'Ich habe den Zugang entsperrt. Bitte versuche es jetzt noch einmal.', '-7 days']]], + ['259906', 'Frage zum Datenschutz', 'Erika Muster', 'Allgemeiner Support', 'Niedrig', 'closed', 'email', 'Lena Hartmann', '-30 days', null, '-29 days', [['customer', null, 'Wo kann ich meine Daten einsehen?', '-30 days'], ['staff', 'Lena Hartmann', 'Unter „Profil → Datenschutz“. Gerne senden wir dir auch einen Auszug.', '-29 days']]], + ['259907', 'Rechnungsadresse falsch', 'Jonas Weber', 'Buchhaltung', 'Normal', 'closed', 'web', 'Lena Hartmann', '-35 days', null, '-33 days', [['customer', null, 'Auf der Rechnung steht eine alte Adresse.', '-35 days'], ['staff', 'Lena Hartmann', 'Korrigiert, die neue Rechnung ist unterwegs.', '-33 days']]], + ] as [$no, $subj, $name, $dn, $pn, $status, $src, $assignee, $created, $due, $resolved, $threads]) { + $last = $threads[count($threads) - 1][3]; + $email = strtolower(str_replace(' ', '.', $name)) . '@kunde.example'; + $tk->execute([$no, $subj, $name, $email, $dept($dn), $prio($pn), $status, $src, $assignee ? $staffId($assignee) : null, $ts($created), $ts($last), + $due ? $ts($due) : null, $ts($created . ' +1 hour'), $assignee ? $ts($created . ' +40 minutes') : null, $resolved ? $ts($resolved) : null, $status === 'closed' ? $ts($resolved ?? $last) : null, + ($due && strtotime($ts($due)) < time() && !in_array($status, ['resolved', 'closed'], true)) ? 1 : 0, $uid($email)]); + $tid = (int) $p->lastInsertId(); + foreach ($threads as [$who, $sn, $msg, $when]) $th->execute([$tid, $who, $sn ? $staffId($sn) : null, $msg, 0, $ts($when)]); + } +}); + +// ── Ticket-Zubehör: Schlagwörter, Zeiten, Verknüpfungen, Notizen, Anhang, Bewertung ─ +$tid = fn(string $no) => (int) $one('SELECT id FROM tickets WHERE ticket_number = ?', [$no]); +$try('Schlagwörter', function () use ($p, $tid) { + $p->exec("INSERT INTO tags (name, color) VALUES ('Zahlung', '#0ea5e9'), ('Störung', '#ef4444'), ('Vertrag', '#8b5cf6'), ('VIP', '#f59e0b')"); + foreach ([['260101', 'Zahlung'], ['260107', 'Zahlung'], ['260107', 'VIP'], ['260108', 'Störung'], ['260105', 'Störung'], ['260104', 'Vertrag'], ['260102', 'VIP']] as [$no, $tag]) + $p->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?, (SELECT id FROM tags WHERE name = ?))')->execute([$tid($no), $tag]); +}); +$try('Zeiten und Notizen', function () use ($p, $ts, $tid, $staffId) { + foreach ([['260108', 'Mira Schulz', 45, 'Logs geprüft, Datenbank langsam'], ['260108', 'Mira Schulz', 30, 'Index neu aufgebaut'], ['260107', 'Lena Hartmann', 15, 'Zahlungseingang geprüft'], ['260105', 'Mira Schulz', 60, 'Absturz nachgestellt']] as [$no, $n, $m, $note]) + $p->prepare('INSERT INTO ticket_time_entries (ticket_id, staff_id, minutes, note, logged_at) VALUES (?,?,?,?,?)')->execute([$tid($no), $staffId($n), $m, $note, $ts('-3 hours')]); + foreach ([['260107', 'Lena Hartmann', 'Buchung bei der Bank prüfen: Erstattung nötig, wenn tatsächlich doppelt abgebucht.', '-3 hours'], ['260108', 'Mira Schulz', 'Ursache vermutlich fehlender Index auf der Bestelltabelle.', '-7 hours']] as [$no, $n, $note, $w]) + $p->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,1,?)')->execute([$tid($no), 'staff', $staffId($n), $note, $ts($w)]); +}); +$try('Verknüpfungen und Mitleser', function () use ($p, $ts, $tid, $lena) { + $p->prepare('INSERT INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$tid('260107'), $tid('260101'), 'related', $lena, $ts('-2 hours')]); + $p->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, added_by, created_at) VALUES (?,?,?,?,?)')->execute([$tid('260101'), 'buchhaltung@kunde.example', 'Buchhaltung Muster GmbH', $lena, $ts('-2 hours')]); + $p->prepare('INSERT INTO problems (title, description, root_cause, workaround, status, created_by, updated_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')->execute(['Webseite langsam am Morgen', 'Viele Kunden melden lange Ladezeiten.', 'Fehlender Datenbank-Index', 'Seite neu laden hilft kurzzeitig', 'workaround', $lena, $lena, $ts('-8 hours'), $ts('-2 hours')]); + $pid = (int) $p->lastInsertId(); + foreach (['260108', '260102'] as $no) $p->prepare('INSERT INTO problem_tickets (problem_id, ticket_id, linked_by, linked_at) VALUES (?,?,?,?)')->execute([$pid, $tid($no), $lena, $ts('-2 hours')]); +}); +$try('Geplante Antwort', function () use ($p, $ts, $tid, $staffId) { + $p->prepare("INSERT INTO scheduled_replies (ticket_id, staff_id, message, set_status, send_at, state, created_at) VALUES (?,?,?,?,?,'pending',?)")->execute([$tid('260103'), $staffId('Tim Roth'), '

Hallo Frau Klein, kurze Nachfrage: Konnten Sie die Adresse inzwischen ändern?

', 'pending', $ts('tomorrow 09:00'), $ts('-1 hour')]); +}); +$try('Bewertungen', function () use ($p, $ts, $tid, $staffId) { + $i = 0; + foreach ([['259901', 'Tim Roth', 5, 'Super schnelle Hilfe, danke!'], ['259902', 'Lena Hartmann', 4, null], ['259903', 'Tim Roth', 5, 'Ersatz war sofort da.'], ['259904', 'Lena Hartmann', 3, 'Hat etwas gedauert.'], ['259905', 'Tim Roth', 5, null], ['259906', 'Lena Hartmann', 4, 'Freundlich und hilfreich.']] as [$no, $n, $r, $c]) { + $p->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, rating, comment, sent_at, answered_at) VALUES (?,?,?,?,?,?,?)')->execute([$tid($no), sprintf('%040x', ++$i * 7919), $staffId($n), $r, $c, $ts('-' . (3 + $i) . ' days'), $ts('-' . (3 + $i) . ' days +2 hours')]); + } +}); +$try('Anhang', function () use ($p, $ts, $tmp, $tid) { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAGQAAAA8CAIAAAAv+yGaAAAAZ0lEQVR4nO3PQQ0AIBAEoFt+zWoRDmZgUkCSM0I5vUPzbBQKpyC6V2gv0N6hvUN7h/YO7R3aO7R3aO/Q3qG9Q3uH9g7tHdo7tHdo79DeIb1Deof0DukdsvrBR/YDBwAAAABJRU5ErkJggg=='); + @file_put_contents("$tmp/uploads/beispiel-fehlermeldung.png", $png); + $thread = (int) $p->query("SELECT id FROM ticket_threads WHERE ticket_id = {$tid('260105')} AND author_type = 'customer' ORDER BY id LIMIT 1")->fetchColumn(); + $p->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$thread, 'Fehlermeldung.png', 'beispiel-fehlermeldung.png', strlen($png), $ts('-5 hours')]); + $p->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$thread, 'Protokoll.txt', 'beispiel-protokoll.txt', 2048, $ts('-5 hours')]); + @file_put_contents("$tmp/uploads/beispiel-protokoll.txt", str_repeat("Beispielprotokoll\n", 100)); +}); + +// ── Aufgaben, Ansichten, Berichte ─────────────────────────────────────────── +$try('Aufgaben', function () use ($p, $ts, $staffId, $tid) { + $ins = $p->prepare('INSERT INTO tasks (ticket_id, title, description, assigned_to, status, due_at, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)'); + foreach ([[$tid('260108'), 'Index auf Bestelltabelle prüfen', 'Mit dem Hosting klären', 'Mira Schulz', 'open', '+1 day'], [null, 'Textbausteine überarbeiten', 'Neue Vorlagen für Rückfragen', 'Tim Roth', 'open', '+3 days'], [$tid('260107'), 'Erstattung prüfen', null, 'Lena Hartmann', 'open', 'today 16:00'], [null, 'Monatsbericht vorbereiten', null, 'Lena Hartmann', 'done', '-2 days']] as [$t, $title, $d, $n, $st, $due]) + $ins->execute([$t, $title, $d, $staffId($n), $st, $ts($due), $staffId('Lena Hartmann'), $ts('-3 days'), $ts('-1 day')]); +}); +$try('Ansichten', function () use ($p, $ts, $lena, $dept) { + $ins = $p->prepare('INSERT INTO saved_queues (staff_id, name, filters, sort_order, created_at, is_shared, updated_at) VALUES (?,?,?,?,?,?,?)'); + foreach ([['Meine überfälligen Tickets', ['assigned' => 'me', 'sla' => 'overdue'], 0], ['Unzugewiesen', ['assigned' => 'none', 'tab' => 'open'], 1], ['Nur Technik', ['tab' => 'open', 'departmentId' => (string) $dept('Technik')], 1]] as $i => [$n, $f, $sh]) + $ins->execute([$lena, $n, json_encode($f), $i, $ts('-10 days'), $sh, $ts('-10 days')]); +}); +$try('Berichte', function () use ($p, $ts, $lena) { + $cfg = fn($dim, $m) => json_encode(['dimension' => $dim, 'metrics' => $m, 'from' => date('Y-m-d', strtotime('-30 days')), 'to' => date('Y-m-d'), 'filters' => [], 'sort' => 'label', 'desc' => false]); + $p->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$lena, 'Monatsbericht je Abteilung', $cfg('department', ['total', 'open', 'resolved']), 1, $ts('-12 days'), $ts('-12 days')]); + $rid = (int) $p->lastInsertId(); + $p->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$lena, 'Tickets je Mitarbeiter', $cfg('agent', ['total', 'resolved', 'avg_first_response']), 0, $ts('-8 days'), $ts('-8 days')]); + $p->prepare("INSERT INTO report_schedules (report_id, staff_id, recipients, frequency, weekday, monthday, send_time, period, enabled, next_run_at, created_at, only_if_data, skip_unchanged) VALUES (?,?,?,?,?,?,?,?,1,?,?,1,1)")->execute([$rid, $lena, 'leitung@demo.example', 'weekly', 1, 1, '08:00', 'last_week', $ts('next monday 08:00'), $ts('-6 days')]); +}); + +// ── Integrationen, E-Mail, Zeiten, Regeln ─────────────────────────────────── +$try('API und Webhooks', function () use ($p, $ts, $lena) { + $p->prepare('INSERT INTO api_keys (name, key_hash, key_prefix, can_create_tickets, can_read_tickets, ip_whitelist, is_active, last_used_at, created_by, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')->execute(['Kontaktformular Website', hash('sha256', 'demo-1'), 'dh_3f9a', 1, 1, null, 1, $ts('-2 hours'), $lena, $ts('-30 days')]); + $p->prepare('INSERT INTO api_keys (name, key_hash, key_prefix, can_create_tickets, can_read_tickets, ip_whitelist, is_active, last_used_at, created_by, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')->execute(['Minecraft-Server', hash('sha256', 'demo-2'), 'dh_77c1', 1, 0, '203.0.113.10', 1, $ts('-1 day'), $lena, $ts('-20 days')]); + $p->prepare('INSERT INTO webhooks (name, url, secret_enc, events, is_active, last_status, last_delivery_at, fail_count, created_at) VALUES (?,?,?,?,?,?,?,?,?)')->execute(['Chat-Benachrichtigung', 'https://hooks.example.org/deskhub', 'demo', 'ticket.created,ticket.replied', 1, 200, $ts('-1 hour'), 0, $ts('-15 days')]); +}); +$try('E-Mail', function () use ($p, $ts, $dept, $prio) { + $ins = $p->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, is_active, notes, created_at) VALUES (?,?,?,?,?,1,?,?)'); + $ins->execute(['support@demo.example', 'Support', $dept('Allgemeiner Support'), $prio('Normal'), 1, 'Standardadresse', $ts('-60 days')]); + $ins->execute(['buchhaltung@demo.example', 'Buchhaltung', $dept('Buchhaltung'), $prio('Hoch'), 0, null, $ts('-60 days')]); + $ins->execute(['technik@demo.example', 'Technik', $dept('Technik'), $prio('Dringend'), 0, null, $ts('-60 days')]); + $p->prepare('INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, department_id, priority_id, is_active, last_run_at, last_success_at, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute(['Support-Postfach', 'imap.demo.example', 993, 'ssl', 'support@demo.example', 'demo', 'INBOX', 'mark_read', $dept('Allgemeiner Support'), $prio('Normal'), 1, $ts('-4 minutes'), $ts('-4 minutes'), $ts('-60 days')]); + $p->prepare("INSERT INTO mail_queue (to_email, subject, body, status, attempts, next_attempt_at, last_error, created_at) VALUES (?,?,?,?,?,?,?,?)")->execute(['erika.muster@kunde.example', 'Ihre Anfrage #260107', '

Text

', 'waiting', 2, $ts('+20 minutes'), 'Verbindung zum Mailserver fehlgeschlagen', $ts('-30 minutes')]); +}); +$try('Zeiten', function () use ($p, $ts) { + $p->exec('DELETE FROM business_hours'); + foreach (range(0, 6) as $w) $p->prepare('INSERT INTO business_hours (weekday, is_open, open_time, close_time) VALUES (?,?,?,?)')->execute([$w, in_array($w, [1, 2, 3, 4, 5], true) ? 1 : 0, '08:00:00', '17:00:00']); + foreach ([['2026-12-25', '1. Weihnachtstag'], ['2026-12-26', '2. Weihnachtstag'], ['2027-01-01', 'Neujahr']] as [$d, $n]) $p->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$d, $n]); +}); +$try('Regeln', function () use ($p, $ts, $dept, $prio, $staffId, $lena) { + $p->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at, notes) VALUES (?,?,?,?,?,?)')->execute(['Rechnungs-Anfragen an die Buchhaltung', 'any', 1, 1, $ts('-40 days'), 'Betreff enthält Rechnung oder Zahlung']); + $r = (int) $p->lastInsertId(); + foreach (['Rechnung', 'Zahlung'] as $w) $p->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)')->execute([$r, 'subject', 'contains', $w]); + $p->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)')->execute([$r, 'set_department', (string) $dept('Buchhaltung')]); + $p->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)')->execute([$r, 'set_priority', (string) $prio('Hoch')]); + $p->prepare("INSERT INTO escalation_rules (name, is_active, trigger_type, trigger_hours, condition_status, action_set_priority_id, action_notify_admin, action_notify_staff, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)")->execute(['Unbeantwortet nach 8 Stunden: Priorität erhöhen', 1, 'no_reply', 8, 'open', $prio('Dringend'), 1, 1, 1, $ts('-40 days')]); + $p->prepare("INSERT INTO escalation_rules (name, is_active, trigger_type, trigger_hours, action_notify_admin, sort_order, created_at) VALUES (?,?,?,?,?,?,?)")->execute(['Überfällig: Admin informieren', 1, 'overdue', 0, 1, 2, $ts('-40 days')]); + $p->prepare('INSERT INTO recurring_tickets (subject, message, department_id, priority_id, assigned_staff_id, interval_days, next_run, is_active, created_by, created_at) VALUES (?,?,?,?,?,?,?,1,?,?)')->execute(['Monatliche Checkliste Backups', 'Backups prüfen, Speicherplatz kontrollieren, Updates einspielen.', $dept('Technik'), $prio('Normal'), $staffId('Mira Schulz'), 30, $ts('+12 days'), $lena, $ts('-18 days')]); +}); +$try('Seiten', function () use ($p, $ts) { + $ins = $p->prepare('INSERT INTO site_pages (slug, title, content, show_in_nav, is_active, sort_order, created_at) VALUES (?,?,?,?,1,?,?)'); + $ins->execute(['impressum', 'Impressum', '

Muster Support GmbH
Beispielstraße 1
12345 Beispielstadt

Alle Angaben sind erfunden.

', 1, 1, $ts('-60 days')]); + $ins->execute(['datenschutz', 'Datenschutz', '

Hier steht in einer echten Installation eure Datenschutzerklärung.

', 1, 2, $ts('-60 days')]); + $ins->execute(['agb', 'Allgemeine Geschäftsbedingungen', '

Beispieltext.

', 0, 3, $ts('-60 days')]); +}); +$try('Statusseite', function () use ($p, $ts, $lena) { + $n = (int) $p->query('SELECT COUNT(*) FROM status_components')->fetchColumn(); + if ($n === 0) { + foreach ([['Website', 'operational'], ['Kundenportal', 'operational'], ['E-Mail-Versand', 'degraded'], ['Telefon-Hotline', 'operational']] as $i => [$nm, $st]) + $p->prepare('INSERT INTO status_components (name, status, sort_order, created_at, is_enabled) VALUES (?,?,?,?,1)')->execute([$nm, $st, $i, $ts('-60 days')]); + } + $cid = (int) $p->query("SELECT id FROM status_components ORDER BY id LIMIT 1 OFFSET 2")->fetchColumn() ?: null; + $p->prepare('INSERT INTO status_incidents (title, body, status, impact, component_id, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')->execute(['Verzögerter E-Mail-Versand', 'Mails kommen mit Verspätung an. Wir arbeiten an der Ursache.', 'monitoring', 'minor', $cid, $lena, $ts('-3 hours'), $ts('-1 hour')]); + $iid = (int) $p->lastInsertId(); + $p->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$iid, 'investigating', 'Wir haben das Problem erkannt.', $lena, $ts('-3 hours')]); + $p->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$iid, 'monitoring', 'Ein Neustart des Mailservers hat geholfen. Wir beobachten.', $lena, $ts('-1 hour')]); +}); +$try('Mitarbeiter-Zubehör', function () use ($p, $ts, $staffId, $lena, $tim) { + foreach ([1, 2, 3, 4, 5] as $w) $p->prepare('INSERT INTO staff_shifts (staff_id, weekday, start_time, end_time, is_active) VALUES (?,?,?,?,1)')->execute([$staffId('Tim Roth'), $w, '08:00', '16:00']); + $p->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, is_read, created_at) VALUES (?,?,?,?,?)')->execute([$staffId('Tim Roth'), $staffId('Lena Hartmann'), 'Kannst du kurz auf 260105 schauen? Android 14.', 1, $ts('-2 hours')]); + $p->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, is_read, created_at) VALUES (?,?,?,?,?)')->execute([$staffId('Lena Hartmann'), $staffId('Tim Roth'), 'Mache ich, danke dir!', 1, $ts('-110 minutes')]); + $p->prepare('INSERT INTO banlist (match_type, value, reason, created_by, created_at) VALUES (?,?,?,?,?)')->execute(['email', 'spam@bad-example.org', 'Wiederholt Werbung', $lena, $ts('-14 days')]); + $p->prepare('INSERT INTO banlist (match_type, value, reason, created_by, created_at) VALUES (?,?,?,?,?)')->execute(['domain', 'wegwerf-mail.example', 'Wegwerf-Adressen', $lena, $ts('-14 days')]); + foreach ([['Anmeldung', 'Mitarbeiter angemeldet: Tim Roth', '-1 hours'], ['Einstellungen', 'Systemeinstellungen geändert', '-1 days'], ['Mitarbeiter', 'Mitarbeiter angelegt: Mira Schulz', '-20 days'], ['Backup', 'Backup erstellt (automatisch)', '-2 days'], ['E-Mail', 'Postfach „Support-Postfach“ abgerufen: 3 neue Mails', '-4 minutes']] as [$k, $a, $w]) + $p->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)')->execute([$lena, $a, $ts($w)]); + $ev = $p->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)'); + $ev->execute([$lena, 'lena@demo.example', 'success', 'web', '203.0.113.5', 'Netz 203.0.x.x', 'Chrome auf Windows', 'Angemeldet', $ts('-1 hour')]); + $ev->execute([$tim, 'tim@demo.example', 'new_device', 'app', '203.0.113.9', 'Netz 203.0.x.x', 'DeskHub-App', 'Neues Gerät', $ts('-1 day')]); + $ev->execute([$tim, 'tim@demo.example', 'failed', 'web', '198.51.100.7', 'Netz 198.51.x.x', 'Firefox auf Linux', 'Falsches Passwort', $ts('-2 days')]); +}); +$try('Einstellungen', function () use ($set) { + foreach (['cron_fetch_emails_last_run' => date('Y-m-d H:i:s', strtotime('+30 days')), 'csat_enabled' => '1', 'csat_delay_minutes' => '30', 'business_hours_enabled' => '1', 'status_page_enabled' => '1', 'auto_assign_on_open' => '1', 'kb_enabled' => '1', 'live_chat_enabled' => '1', 'display_name' => 'Muster Support', 'wait_reminder_enabled' => '1', 'wait_reminder_hours' => '4'] as $k => $v) $set->execute([$k, $v]); +}); + +// Lenas Woche: ein paar gelöste Tickets mit Bewertungen, damit „Meine Woche“ gefüllt aussieht; eine Erwähnung +$try('Meine Woche', function () use ($p, $ts, $dept, $prio, $lena, $tim) { + $tk = $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at, resolution_due_at, first_response_due_at, first_responded_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); + $th = $p->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'); + $monday = strtotime('monday this week 09:00'); + foreach ([['260021', 'Rechnung nachgereicht', 0, 3, 5, 'Buchhaltung'], ['260022', 'Adresse geändert', 0, 2, 5, 'Allgemeiner Support'], ['260023', 'Zugang eingerichtet', 1, 5, 4, 'Allgemeiner Support'], ['260024', 'Passwort zurückgesetzt', 1, 1, 5, 'Allgemeiner Support'], ['260025', 'Frage zur Lieferung', 2, 4, 3, 'Allgemeiner Support']] as [$no, $subj, $dayOff, $hours, $rating, $dn]) { + $created = date('Y-m-d H:i:s', $monday + $dayOff * 86400); + $resolved = date('Y-m-d H:i:s', $monday + $dayOff * 86400 + $hours * 3600); + if (strtotime($resolved) > time()) { $created = date('Y-m-d H:i:s', time() - 7200 - $hours * 3600); $resolved = date('Y-m-d H:i:s', time() - 3600); } + $tk->execute([$no, $subj, 'Kunde ' . $no, 'k' . $no . '@kunde.example', $dept($dn), $prio('Normal'), 'resolved', 'web', $lena, $created, $resolved, date('Y-m-d H:i:s', strtotime($created) + 86400), date('Y-m-d H:i:s', strtotime($created) + 3600), date('Y-m-d H:i:s', strtotime($created) + 1500 + $hours * 300), $resolved]); + $tid = (int) $p->lastInsertId(); + $th->execute([$tid, 'customer', null, 'Anfrage: ' . $subj, $created]); + $th->execute([$tid, 'staff', $lena, 'Erledigt, danke für die Geduld.', date('Y-m-d H:i:s', strtotime($created) + 1500 + $hours * 300)]); + $p->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, rating, comment, sent_at, answered_at) VALUES (?,?,?,?,?,?,?)')->execute([$tid, sprintf('%040x', crc32($no) * 31), $lena, $rating, null, $resolved, $resolved]); + } + $t = (int) $p->query("SELECT id FROM tickets WHERE ticket_number = '260108'")->fetchColumn(); + $thr = (int) $p->query("SELECT id FROM ticket_threads WHERE ticket_id = {$t} AND is_internal_note = 1 ORDER BY id LIMIT 1")->fetchColumn(); + $p->prepare('INSERT INTO staff_mentions (ticket_id, thread_id, mentioned_staff_id, mentioned_by_staff_id, is_read, created_at) VALUES (?,?,?,?,0,?)')->execute([$t, $thr, $lena, $tim, $ts('-1 hour')]); +}); + +echo "Handbuch-Daten ergänzt.\n"; +foreach ($warn as $w) echo "WARNUNG $w\n";