Upload via GUI (267 Dateien)
This commit is contained in:
+68
-16
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**DeskHub** ist ein vollständiges, selbst gehostetes Helpdesk-System. Kunden erstellen und verfolgen Supportanfragen über ein individuell gestaltbares Kundenportal. Das Support-Team bearbeitet Tickets, E-Mails, Aufgaben und Wissensartikel zentral über die Weboberfläche, die Windows-App oder die Android-App.
|
**DeskHub** ist ein vollständiges, selbst gehostetes Helpdesk-System. Kunden erstellen und verfolgen Supportanfragen über ein individuell gestaltbares Kundenportal. Das Support-Team bearbeitet Tickets, E-Mails, Aufgaben und Wissensartikel zentral über die Weboberfläche, die Windows-App oder die Android-App.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> Diese Anleitung ist auch für Einsteiger geeignet. Über das Inhaltsverzeichnis kommt ihr direkt zum gewünschten Thema. Die Abschnitte [Plugins entwickeln](#plugins-entwickeln) und [API und Webhooks](#api-und-webhooks-für-entwickler) richten sich an Entwickler.
|
> Diese Anleitung ist auch für Einsteiger geeignet. Über das Inhaltsverzeichnis kommt ihr direkt zum gewünschten Thema. Die Abschnitte [Plugins entwickeln](#plugins-entwickeln) und [API und Webhooks](#api-und-webhooks-für-entwickler) richten sich an Entwickler.
|
||||||
|
|
||||||
@@ -25,15 +25,15 @@
|
|||||||
|
|
||||||
### Neues Ticket erstellen
|
### Neues Ticket erstellen
|
||||||
|
|
||||||
[](docs/images/new-ticket.png)
|
[](DeskHub-Webseite/docs/images/new-ticket.png)
|
||||||
|
|
||||||
### Kunden-Login
|
### Kunden-Login
|
||||||
|
|
||||||
[](docs/images/customer-login.png)
|
[](DeskHub-Webseite/docs/images/customer-login.png)
|
||||||
|
|
||||||
### Mitarbeiter-Login
|
### Mitarbeiter-Login
|
||||||
|
|
||||||
[](docs/images/staff-login.png)
|
[](DeskHub-Webseite/docs/images/staff-login.png)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,7 +98,9 @@ Ein Admin-Konto ist ein Mitarbeiter-Konto mit der Rolle „Admin“. Beim Anmeld
|
|||||||
3. Dem Assistenten folgen: Datenbank-Zugangsdaten, Website-Name, erstes Admin-Konto.
|
3. Dem Assistenten folgen: Datenbank-Zugangsdaten, Website-Name, erstes Admin-Konto.
|
||||||
4. Danach unter `eure-domain.de/staff/login.php` mit dem neuen Konto anmelden.
|
4. Danach unter `eure-domain.de/staff/login.php` mit dem neuen Konto anmelden.
|
||||||
|
|
||||||
**Ganz wichtig:** Löscht anschließend den Ordner `install` vom Server (oder sperrt ihn). Sonst könnte jemand die Installation ein zweites Mal starten und eure Daten überschreiben.
|
Der Assistent prüft zuerst die Voraussetzungen (PHP 8.2, Datenbanktreiber, `openssl`, beschreibbare Ordner; `curl`, `zip` und HTTPS sind empfohlen). Das Admin-Passwort braucht mindestens 10 Zeichen mit Groß- und Kleinbuchstaben, Ziffer und Sonderzeichen. Zum Schluss spielt er alle Datenbank-Änderungen ein, erzeugt den Verschlüsselungsschlüssel und sperrt sich selbst. Fehler bei der Datenbankverbindung werden verständlich erklärt (falsches Passwort, Server nicht erreichbar, Datenbank fehlt).
|
||||||
|
|
||||||
|
**Ganz wichtig:** Löscht anschließend den Ordner `install` vom Server (oder sperrt ihn). Der Assistent überschreibt eine bestehende Installation nie: Gibt es schon eine `config/config.php` mit Datenbank und Mitarbeiterkonten, zeigt er „Bereits installiert“. Wollt ihr bewusst neu anfangen, legt zuerst ein Backup an und entfernt dann `config/config.php`, `install/.installed` und die Datenbank (bei SQLite die Datei `database/deskhub.sqlite`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,8 +149,16 @@ Im Mitarbeiterbereich gibt es links im Menü:
|
|||||||
- **Sperrliste**: einzelne E-Mail-Adressen oder ganze Mail-Anbieter blockieren
|
- **Sperrliste**: einzelne E-Mail-Adressen oder ganze Mail-Anbieter blockieren
|
||||||
- **Verzeichnis**: Kontaktliste aller Kollegen
|
- **Verzeichnis**: Kontaktliste aller Kollegen
|
||||||
|
|
||||||
|
**Vorlagen (Textbausteine):** Im Antwortformular zeigt die Auswahl „Vorlage wählen“ nur Vorlagen, die der **Abteilung des Tickets** zugeordnet sind, dazu die Vorlagen für **alle Abteilungen** (ohne Abteilung angelegt). Beides ist in der Auswahl gruppiert. Zuordnen könnt ihr eine Vorlage unter Adminportal → Vorlagen.
|
||||||
|
|
||||||
|
**Mehrere Tickets in eine andere Abteilung verschieben:** In der Ticketliste die Tickets markieren, unten bei „Auswahl“ die Abteilung wählen und **„Abteilung ändern“** klicken. Die SLA-Fristen werden nach den Regeln der neuen Abteilung neu berechnet, die Verschiebung steht im Aktivitätsprotokoll jedes Tickets. Verschieben dürfen nur Mitarbeiter mit dem Recht „Tickets übertragen“ und nur in Abteilungen, auf die sie Zugriff haben. Tickets, die schon in der Zielabteilung liegen, oder bei denen das Recht fehlt, werden übersprungen und in der Meldung genannt.
|
||||||
|
|
||||||
In einem Ticket könnt ihr dem Kunden antworten, eine **interne Notiz** hinterlassen (nur das Team sieht sie), den Status ändern, das Ticket zuweisen, lösen oder schließen. Welche dieser Aktionen möglich sind, hängt von eurer Rolle in der Abteilung des Tickets ab.
|
In einem Ticket könnt ihr dem Kunden antworten, eine **interne Notiz** hinterlassen (nur das Team sieht sie), den Status ändern, das Ticket zuweisen, lösen oder schließen. Welche dieser Aktionen möglich sind, hängt von eurer Rolle in der Abteilung des Tickets ab.
|
||||||
|
|
||||||
|
**Signatur beim Antworten wählen:** Im Antwortformular eines Tickets steht unter dem Textfeld die Auswahl **Signatur**: **Meine Signatur**, **Abteilung „…“** (die Signatur der Abteilung des Tickets) oder **Keine Signatur**. Darunter zeigt eine Vorschau genau das, was angehängt wird. Voreingestellt ist wie bisher die eigene Signatur, ersatzweise die der Abteilung. Die Signatur wird nur an öffentliche Antworten angehängt, nicht an interne Notizen. Die Mobile-API nimmt beim Antworten (`POST /api/staff/v1/ticket_message.php`) optional das Feld `signature` mit `own`, `dept` oder `none`.
|
||||||
|
|
||||||
|
**E-Mail-Signatur:** Unter **Mein Profil → Signatur** stellt ihr den Text ein, der an eure Antworten angehängt wird (Fett, Kursiv, Liste, Link und Bilder). Bilder fügt ihr mit 🖼️ ein, die Größe ändert ihr, indem ihr das Bild anklickt: Mit dem runden Anfasser an der Ecke ziehen (auch mit dem Finger) oder 25 / 50 / 75 % / Original wählen. Das funktioniert in allen Texteditoren mit Bildern, auch bei Abteilungen und Mitarbeiter-Konten der Verwaltung. Bilder in Signaturen müssen über eine https-Adresse erreichbar sein (die Website-URL der Installation muss mit https:// beginnen), sonst blendet der Sicherheitsfilter sie aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Für Admins: Das Adminportal
|
## Für Admins: Das Adminportal
|
||||||
@@ -202,9 +212,17 @@ Im Menü gibt es den Punkt **„Adminportal“**. Er ist für echte Admins immer
|
|||||||
| Backup | Manuelle und automatische Sicherungen |
|
| Backup | Manuelle und automatische Sicherungen |
|
||||||
| System-Update | Programm und Datenbank aktualisieren |
|
| System-Update | Programm und Datenbank aktualisieren |
|
||||||
|
|
||||||
|
### Firmenlogo
|
||||||
|
|
||||||
|
Unter **Einstellungen → Design → Firmenlogo** ladet ihr euer Logo hoch (PNG, JPG, GIF oder SVG, höchstens 2 MB). Es erscheint im Kundenportal, in den Anmeldeseiten und im Mitarbeiterbereich. Neben dem Logo steht standardmäßig der Name der Installation; mit **„Name neben dem Logo ausblenden“** zeigt DeskHub nur das Logo. Die Vorschau in den Einstellungen hat einen dunklen Hintergrund. Helle Logos sind auf den hellen Seiten des Kundenportals schwer zu erkennen, nehmt dafür lieber eine dunkle oder farbige Variante.
|
||||||
|
|
||||||
|
### Favicon
|
||||||
|
|
||||||
|
Das Symbol im Browser-Tab, in Lesezeichen und auf dem Startbildschirm stellt ihr unter **Einstellungen → Design → Favicon** ein. Erlaubt sind ICO, PNG, JPG, GIF, WebP und SVG (höchstens 1 MB, am besten quadratisch, z. B. 256×256 Pixel). DeskHub prüft den Inhalt, nicht nur die Endung; SVG-Dateien mit Skripten werden abgelehnt. Das Favicon gilt auf allen Seiten (Kundenportal, Mitarbeiterbereich, Anmeldeseiten, Druckansichten). Ohne Favicon verwendet DeskHub das Logo, ohne beides das Standardsymbol. Browser merken sich Symbole hartnäckig: Nach einer Änderung hilft Strg + F5 oder ein neuer Tab.
|
||||||
|
|
||||||
### Kundenportal-Design (Themes)
|
### Kundenportal-Design (Themes)
|
||||||
|
|
||||||
Unter **Einstellungen → Design** lassen sich mehrere Themes als ZIP-Datei hochladen, aktivieren und löschen. Ein aktiviertes Theme gilt für das ganze Kundenportal. Mit **„Standarddesign nutzen“** wird es wieder deaktiviert. Der Anzeigename wird aus dem Dateinamen der ZIP übernommen; lädt man ein Theme mit gleichem Namen erneut hoch, ersetzt es die vorhandene Version. Fertige Themes liegen im Ordner `theme-packages/`. Außerdem könnt ihr Themes direkt aus dem Katalog der Plugin-Webseite installieren: Auf derselben Seite erscheint unter **„Themes aus dem Katalog“** das Angebot automatisch. Prüfsumme und Signatur (mit dem unter Plugins hinterlegten Herausgeber-Schlüssel) werden vor der Installation geprüft; aktiviert wird das Theme erst, wenn ihr es selbst aktiviert.
|
Unter **Einstellungen → Design** lassen sich mehrere Themes als ZIP-Datei hochladen, aktivieren und löschen. Ein aktiviertes Theme gilt für das ganze Kundenportal. Mit **„Standarddesign nutzen“** wird es wieder deaktiviert. Der Anzeigename wird aus dem Dateinamen der ZIP übernommen; lädt man ein Theme mit gleichem Namen erneut hoch, ersetzt es die vorhandene Version. Fertige Themes (darunter die Saison-Themes **Halloween Nacht** und **Weihnachten Winter**) liegen als ZIP im Ordner `theme/` des Projekts. Außerdem könnt ihr Themes direkt aus dem Katalog der Plugin-Webseite installieren: Auf derselben Seite erscheint unter **„Themes aus dem Katalog“** das Angebot automatisch. Prüfsumme und Signatur (mit dem unter Plugins hinterlegten Herausgeber-Schlüssel) werden vor der Installation geprüft; aktiviert wird das Theme erst, wenn ihr es selbst aktiviert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,6 +257,10 @@ Damit eine Mail an z. B. `support@eure-domain.de` automatisch als Ticket erschei
|
|||||||
|
|
||||||
Habt ihr getrennte Postfächer pro Abteilung (z. B. `technik@` und `vertrieb@`), legt ihr für jedes ein eigenes Postfach mit eigener Standardabteilung an. DeskHub ruft alle aktiven Postfächer nacheinander ab.
|
Habt ihr getrennte Postfächer pro Abteilung (z. B. `technik@` und `vertrieb@`), legt ihr für jedes ein eigenes Postfach mit eigener Standardabteilung an. DeskHub ruft alle aktiven Postfächer nacheinander ab.
|
||||||
|
|
||||||
|
### Eigenes Ticket-Formular pro Abteilung
|
||||||
|
|
||||||
|
Unter **Abteilungen → Formular** (oder im Reiter „Ticket-Formular“ der Abteilung) kann jede Abteilung ein komplett eigenes Formular bekommen. Ist es aktiv, sieht ein Kunde, der diese Abteilung wählt (direkt oder über ein Hilfe-Thema), nur die dort festgelegten Felder: eigene Felder (Text, Mehrzeilig, Auswahl, Checkbox, Zahl, Datum, mit Pflicht und Reihenfolge), Überschrift, Einleitung, Bezeichnungen von Betreff und Beschreibung, Platzhaltertext und Text des Absende-Knopfs. Betreff und Anhang lassen sich ausblenden (ohne Betreff wird „Anfrage <Abteilung>“ verwendet). Die allgemeinen Ticket-Felder und die Felder der Hilfe-Themen gelten dann für diese Abteilung nicht. Name und E-Mail werden immer abgefragt. Abteilungen ohne eigenes Formular nutzen weiter das Standardformular.
|
||||||
|
|
||||||
### Eine Adresse einer Abteilung zuordnen
|
### Eine Adresse einer Abteilung zuordnen
|
||||||
|
|
||||||
Läuft alles in einem Sammelpostfach zusammen (z. B. per Weiterleitung), muss DeskHub wissen, welche Empfängeradresse zu welcher Abteilung gehört:
|
Läuft alles in einem Sammelpostfach zusammen (z. B. per Weiterleitung), muss DeskHub wissen, welche Empfängeradresse zu welcher Abteilung gehört:
|
||||||
@@ -371,6 +393,7 @@ Beide Apps sind für Mitarbeiter und Admins gedacht und haben ein helles und ein
|
|||||||
**Was die Apps können:**
|
**Was die Apps können:**
|
||||||
|
|
||||||
- Tickets bearbeiten (antworten, Notizen, zuweisen, Status ändern, lösen, schließen), Übersicht, Kundenverzeichnis, internen Chat mit Kollegen
|
- Tickets bearbeiten (antworten, Notizen, zuweisen, Status ändern, lösen, schließen), Übersicht, Kundenverzeichnis, internen Chat mit Kollegen
|
||||||
|
- Beim Antworten zeigen die Apps nur die Textbausteine der Abteilung des Tickets (plus die für alle Abteilungen) und bieten wie im Web die **Signatur-Auswahl** (meine, Abteilung, keine) mit Vorschau. Das setzt einen Server mit diesem Stand voraus; ältere Server liefern die Signaturen nicht, dann antworten die Apps wie bisher.
|
||||||
- Gespeicherte Ansichten nutzen und Berichte aus dem Bericht-Baukasten ansehen
|
- Gespeicherte Ansichten nutzen und Berichte aus dem Bericht-Baukasten ansehen
|
||||||
- Störungs- und Verbindungsbanner
|
- Störungs- und Verbindungsbanner
|
||||||
- Admins und Co-Admins finden zusätzlich die **Verwaltung** mit fast allen Adminportal-Bereichen, je nach zugewiesenen Rechten: Abteilungen, Prioritäten, SLA-Pläne, Hilfethemen, E-Mail-Adressen (inkl. Test), Wissensdatenbank, E-Mail-Vorlagen, Organisationen, Mitarbeiter, Teams, Rollen, Admin-Protokoll, Eskalationsregeln, Zeiterfassung, Geschäftszeiten, Sperrliste, Aufgaben
|
- Admins und Co-Admins finden zusätzlich die **Verwaltung** mit fast allen Adminportal-Bereichen, je nach zugewiesenen Rechten: Abteilungen, Prioritäten, SLA-Pläne, Hilfethemen, E-Mail-Adressen (inkl. Test), Wissensdatenbank, E-Mail-Vorlagen, Organisationen, Mitarbeiter, Teams, Rollen, Admin-Protokoll, Eskalationsregeln, Zeiterfassung, Geschäftszeiten, Sperrliste, Aufgaben
|
||||||
@@ -388,7 +411,7 @@ Adminportal → **System-Update** hat zwei Teile:
|
|||||||
|
|
||||||
**Datenbank aktualisieren:** Die Karte „Datenbank“ zeigt, ob die Datenbank zum installierten Programm passt. Wenn nicht, erscheint dort und für Admins oben auf jeder Seite ein Hinweis. Der Knopf **„Datenbank aktualisieren“** erstellt zuerst ein Backup und spielt dann alle fehlenden Änderungen ein. Das ist der richtige Weg, wenn ihr Dateien per FTP hochgeladen habt. Scheitert das Backup, wird die Datenbank nicht angefasst.
|
**Datenbank aktualisieren:** Die Karte „Datenbank“ zeigt, ob die Datenbank zum installierten Programm passt. Wenn nicht, erscheint dort und für Admins oben auf jeder Seite ein Hinweis. Der Knopf **„Datenbank aktualisieren“** erstellt zuerst ein Backup und spielt dann alle fehlenden Änderungen ein. Das ist der richtige Weg, wenn ihr Dateien per FTP hochgeladen habt. Scheitert das Backup, wird die Datenbank nicht angefasst.
|
||||||
|
|
||||||
**Signierte Updates:** Ist in DeskHub ein Herausgeber-Schlüssel eingebaut (`includes/catalog_defaults.php`), installiert das System-Update nur Releases, die mit diesem Schlüssel signiert sind. Ein manipuliertes Paket oder ein Release ohne Signatur wird abgelehnt und im Admin-Protokoll vermerkt. Ein manuell hochgeladenes ZIP wird nicht geprüft, ladet nur Pakete aus vertrauenswürdiger Quelle hoch. Es steckt kein Zugangstoken mehr im Programmcode: Die Releases werden öffentlich gelesen. Ist das Repository privat, tragt ihr in `config/config.php` ein Lese-Token ein: `'update' => ['token' => '…']` (nur Leserecht vergeben).
|
**Signierte Updates:** Ist in DeskHub ein Herausgeber-Schlüssel eingebaut (`includes/catalog_defaults.php`), installiert das System-Update nur Releases, die mit diesem Schlüssel signiert sind. Ein manipuliertes Paket oder ein Release ohne Signatur wird abgelehnt und im Admin-Protokoll vermerkt. Ein manuell hochgeladenes ZIP wird nicht geprüft, ladet nur Pakete aus vertrauenswürdiger Quelle hoch. Es steckt kein Zugangstoken mehr im Programmcode: Die Releases werden öffentlich gelesen. Ist das Repository privat (Gitea meldet beim Laden der Versionen „404“), tragt ihr ein Lese-Token ein: am einfachsten im Adminportal unter **System-Update → Zugang zum Update-Server** (wird verschlüsselt gespeichert), oder in `config/config.php` im großen `return [ … ];` als `'update' => ['token' => '…'],`. In Gitea erzeugt ihr es unter Einstellungen → Anwendungen → Zugriffstoken mit der Berechtigung „repository: Lesen“ (nur Leserecht vergeben). Ein Eintrag in der `config.php` hat Vorrang.
|
||||||
|
|
||||||
**Release veröffentlichen (für den Herausgeber):**
|
**Release veröffentlichen (für den Herausgeber):**
|
||||||
|
|
||||||
@@ -415,7 +438,7 @@ Wer von osTicket auf DeskHub umzieht, kann den Bestand übernehmen: Kunden, Abte
|
|||||||
|
|
||||||
Adminportal → **Einstellungen → „📥 osTicket Import“**.
|
Adminportal → **Einstellungen → „📥 osTicket Import“**.
|
||||||
|
|
||||||
1. **Hochladen:** Entweder den Dateinamen eintragen, wenn die SQL-Datei im DeskHub-Hauptordner liegt (**empfohlen**, vor allem bei großen Dateien), oder die Datei direkt hochladen (sie wird in 5-MB-Teilen übertragen).
|
1. **Hochladen:** Entweder den Dateinamen eintragen, wenn die SQL-Datei im DeskHub-Hauptordner liegt (**empfohlen**, vor allem bei großen Dateien), oder die Datei direkt hochladen (sie wird in Teilen übertragen, deren Größe zum Upload-Limit des Servers passt).
|
||||||
2. **Analysieren:** DeskHub liest die Datei und zeigt, was sie enthält (Kunden, Tickets, Nachrichten, Abteilungen, Hilfethemen, Anhänge). Der Fortschrittsbalken läuft von selbst, das Fenster muss offen bleiben.
|
2. **Analysieren:** DeskHub liest die Datei und zeigt, was sie enthält (Kunden, Tickets, Nachrichten, Abteilungen, Hilfethemen, Anhänge). Der Fortschrittsbalken läuft von selbst, das Fenster muss offen bleiben.
|
||||||
3. **Optionen:** Auswählen, was importiert werden soll. Die Reihenfolge ist fest: Kunden → Abteilungen und Hilfethemen → Tickets → Nachrichten → Anhänge. Anhänge setzen die Nachrichten voraus.
|
3. **Optionen:** Auswählen, was importiert werden soll. Die Reihenfolge ist fest: Kunden → Abteilungen und Hilfethemen → Tickets → Nachrichten → Anhänge. Anhänge setzen die Nachrichten voraus.
|
||||||
4. **Import:** Läuft in kleinen Schritten, mit Fortschritt je Bereich. Am Ende steht eine Zusammenfassung mit Hinweisen.
|
4. **Import:** Läuft in kleinen Schritten, mit Fortschritt je Bereich. Am Ende steht eine Zusammenfassung mit Hinweisen.
|
||||||
@@ -478,15 +501,40 @@ Katalog und Schlüssel sind **fest in DeskHub eingebaut** (`includes/catalog_def
|
|||||||
|
|
||||||
### Mitgeliefertes Plugin: Tagesbericht per E-Mail
|
### Mitgeliefertes Plugin: Tagesbericht per E-Mail
|
||||||
|
|
||||||
Unter `docs/plugins/tagesbericht-1.0.0.zip` liegt ein fertiges Plugin. Es schickt Mitarbeitern jeden Morgen eine Übersicht: offene, unzugewiesene, überfällige und eigene Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die fünf ältesten offenen Tickets. Jeder sieht nur Tickets seiner Abteilungen.
|
Unter `Plugins/plugins/tagesbericht-1.0.0.zip` (im Projektordner neben `DeskHub-Webseite/`) liegt ein fertiges Plugin. Es schickt Mitarbeitern jeden Morgen eine Übersicht: offene, unzugewiesene, überfällige und eigene Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die fünf ältesten offenen Tickets. Jeder sieht nur Tickets seiner Abteilungen.
|
||||||
|
|
||||||
Nach Installation und Aktivierung findet ihr im Menü den Punkt **„Tagesbericht“**: Dort schaltet ihr den Versand ein, legt Uhrzeit, Werktage und Empfänger fest und könnt einen **Testbericht** an euch schicken. Voraussetzung ist der eingerichtete E-Mail-Versand.
|
Nach Installation und Aktivierung findet ihr im Menü den Punkt **„Tagesbericht“**: Dort schaltet ihr den Versand ein, legt Uhrzeit, Werktage und Empfänger fest und könnt einen **Testbericht** an euch schicken. Voraussetzung ist der eingerichtete E-Mail-Versand.
|
||||||
|
|
||||||
|
### Mitgeliefertes Plugin: Synology NAS Backup (WebDAV)
|
||||||
|
|
||||||
|
Unter `Plugins/plugins/nas-backup-2.2.0.zip` liegt ein Plugin, das die Backups eures Servers automatisch auf eine Synology NAS (oder einen anderen WebDAV-Server) kopiert. Es ist aufgebaut wie das Plugin „Tagesbericht“: Es braucht nur die Standard-Berechtigungen **hooks.cron** und **ui.menu** und verändert nichts am Kern von DeskHub. Installation: Adminportal → Plugins → Zip hochladen → Berechtigungen bestätigen → aktivieren. Danach gibt es auf der Seite **Backup** den Reiter **„Synology NAS Backup“** (kein eigener Menüpunkt in der Seitenleiste; nur Administratoren sehen ihn). Dafür braucht das Plugin keine zusätzliche Berechtigung.
|
||||||
|
|
||||||
|
**Auf der Synology einrichten:**
|
||||||
|
1. Paket **„WebDAV Server“** installieren und **HTTPS (Port 5006)** aktivieren.
|
||||||
|
2. Einen eigenen Benutzer anlegen, der nur Zugriff auf einen freigegebenen Ordner (zum Beispiel `backup`) hat.
|
||||||
|
3. Den Port im Router freigeben (oder einen Reverse-Proxy auf 443 nutzen) und die NAS über eine feste Adresse erreichbar machen (zum Beispiel Synology-DDNS `meine-nas.synology.me`) mit einem gültigen Zertifikat (Let’s Encrypt in der Synology-Systemsteuerung).
|
||||||
|
|
||||||
|
**So arbeitet es mit der Seite „Backup“ zusammen:** Das Plugin schaut alle paar Minuten in den Backup-Ordner des Servers. Jedes Backup, das dort neu liegt (Zeitplan der Seite „Backup“, „Backup jetzt erstellen“, Backups vor Updates), wird auf die NAS kopiert, sobald es geprüft ist (defekte Backups werden nie kopiert). Den Zeitplan stellt ihr also wie gewohnt auf der Seite „Backup“ ein; ist er dort ausgeschaltet, entstehen keine Backups und es gibt nichts zu kopieren. Alle Backups werden **automatisch auf Wiederherstellbarkeit geprüft** (siehe Abschnitt Backup), ohne dass jemand „Prüfen“ klicken muss.
|
||||||
|
|
||||||
|
**Auch die Liste „Vorhandene Backups“ auf der Seite „Backup“** zeigt die NAS: Jede Sicherung trägt die Marke „☁ auf NAS“ oder „nur hier“ (mit Knopf „Auf NAS“), und Sicherungen, die **nur auf der NAS** liegen, stehen ebenfalls in der Liste (mit „Auf Server holen“ und „Von NAS löschen“). Danach lassen sie sich wie jedes andere Backup wiederherstellen. Dafür braucht das Plugin keine zusätzliche Berechtigung (die Liste darf jedes Plugin ergänzen, das einen Reiter unter „Backup“ anbietet).
|
||||||
|
|
||||||
|
**Die Seite „Synology NAS Backup“** zeigt oben den Status, darunter die Liste der Backups (auf dem Server / auf der NAS, geprüft / defekt) und die Einstellungen:
|
||||||
|
- **Verbindung:** IP-Adresse oder Domain, Port (Synology: 5005 HTTP / 5006 HTTPS, über Reverse-Proxy 443), Benutzer, Passwort, HTTPS, Zielverzeichnis auf der NAS (beginnt mit dem Namen des freigegebenen Ordners, zum Beispiel `/usbshare2/Geschichten/`). Die Server-URL wird als Vorschau angezeigt, **„Verbindung testen“** prüft alles.
|
||||||
|
- **Aufbewahrung:** maximales Alter in Tagen **und** maximale Anzahl, beide Regeln gelten gleichzeitig (0 = aus); „Aktuell aktive Regeln“ zeigt, was gilt. Gelöscht werden nur Dateien, die das Plugin selbst angelegt hat (`backup_….zip`).
|
||||||
|
- **Backups einzeln bewegen:** „Auf NAS“ für ein Backup, das noch nicht dort liegt, „Fehlende Backups jetzt kopieren“ für alle. Sicherungen, die **nur auf der NAS** liegen (zum Beispiel nach einem Serverumzug), holt ihr mit „Auf Server holen“; sie werden geprüft und lassen sich dann auf der Seite „Backup“ wiederherstellen.
|
||||||
|
- **Eigene Backups des Plugins** (optional): Mit eigenem Zeitplan (stündlich bis monatlich, Uhrzeit), Inhalt (Datenbank und/oder Dateien, „wichtigste Dateien“ oder alles, Pfade ausschließen) erstellt das Plugin zusätzlich Backups, die nur auf der NAS liegen. „Eigenes Backup jetzt starten“ läuft im Hintergrund.
|
||||||
|
|
||||||
|
Gut zu wissen:
|
||||||
|
- Jedes Backup wird nach dem Hochladen auf der NAS auf vollständige Größe kontrolliert; unvollständige Dateien werden wieder gelöscht.
|
||||||
|
- Das Passwort wird verschlüsselt gespeichert. Zugangsdaten werden nie an Weiterleitungen geschickt. Ohne HTTPS sind nur Adressen im eigenen Netz erlaubt. Die Zertifikatsprüfung ist standardmäßig an und lässt sich für ein selbst erstelltes Zertifikat abschalten (dann weniger sicher).
|
||||||
|
- Schlägt das Kopieren fehl, versucht das Plugin es frühestens nach einer Stunde erneut; die Admin-Adresse bekommt höchstens einmal am Tag eine E-Mail, und der Fehler steht mit Ursache auf der Seite des Plugins.
|
||||||
|
- Das Plugin läuft über den eingebauten Cron (bei Aktivität im Adminportal) oder einen echten Cronjob, der DeskHub aufruft.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plugins entwickeln
|
## Plugins entwickeln
|
||||||
|
|
||||||
Dieser Abschnitt richtet sich an Entwickler. Ein vollständiges, lauffähiges Beispiel liegt in `docs/plugin-beispiel/`, ein praxisnahes in `docs/plugins/tagesbericht/`.
|
Dieser Abschnitt richtet sich an Entwickler. Ein vollständiges, lauffähiges Beispiel liegt in `Plugins/plugin-beispiel/`, ein praxisnahes in `Plugins/plugins/tagesbericht/` (jeweils im Projektordner neben `DeskHub-Webseite/`).
|
||||||
|
|
||||||
### Aufbau
|
### Aufbau
|
||||||
|
|
||||||
@@ -629,7 +677,7 @@ plugin_register_menu('Ticket-Zähler', 'uebersicht');
|
|||||||
|
|
||||||
### Signieren und veröffentlichen
|
### Signieren und veröffentlichen
|
||||||
|
|
||||||
Wer Plugins oder Themes verteilt, signiert sie mit einem eigenen Schlüsselpaar (RSA, SHA-256). Das Werkzeug dafür liegt im Entwickler-Paket unter `tools/plugin-sign.php`:
|
Wer Plugins oder Themes verteilt, signiert sie mit einem eigenen Schlüsselpaar (RSA, SHA-256). Das Werkzeug dafür liegt im Entwickler-Paket unter `tools/plugin-sign.php` (für DeskHub-Releases selbst gibt es `tools/release-sign.php`, siehe [System-Update](#system-update-und-datenbank-update)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php tools/plugin-sign.php keygen ./schluessel # einmalig: privater + öffentlicher Schlüssel
|
php tools/plugin-sign.php keygen ./schluessel # einmalig: privater + öffentlicher Schlüssel
|
||||||
@@ -656,9 +704,9 @@ Für Themes heißt die Liste `themes` (ohne `requires` und `permissions`). Adres
|
|||||||
|
|
||||||
## Plugin- und Theme-Webseite
|
## Plugin- und Theme-Webseite
|
||||||
|
|
||||||
Im Ordner `plugin-webseite/` liegt eine fertige, eigenständige Webseite, von der DeskHub-Installationen Plugins und Themes laden. Sie läuft auf eigenem Webspace (PHP 8.1, SQLite) und braucht kein DeskHub. Sie bietet den Katalog für DeskHub und eine geschützte Verwaltung zum Veröffentlichen mit allen Versionen, Prüfsummen und Signaturstatus. Es gibt keine öffentliche Übersichtsseite. Hochgeladene Pakete werden geprüft (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und automatisch mit eurem Schlüssel signiert. Einrichtung in zwei Minuten per `php einrichten.php`, Details in `plugin-webseite/README.md`.
|
Im Projektordner `plugin-webseite/` (neben `DeskHub-Webseite/`, nicht Teil der DeskHub-Installation auf Kunden-Servern) liegt eine fertige, eigenständige Webseite, von der DeskHub-Installationen Plugins und Themes laden. Sie läuft auf eigenem Webspace (PHP 8.1, SQLite) und braucht kein DeskHub. Sie bietet den Katalog für DeskHub und eine geschützte Verwaltung zum Veröffentlichen mit allen Versionen, Prüfsummen und Signaturstatus. Es gibt keine öffentliche Übersichtsseite. Hochgeladene Pakete werden geprüft (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und automatisch mit eurem Schlüssel signiert. Einrichtung in zwei Minuten per `php einrichten.php`, Details in `plugin-webseite/README.md`.
|
||||||
|
|
||||||
**Geprüfte Plugins für eure Kunden:** `php einrichten.php` (und in der Verwaltung der Bereich „Einstellungen für DeskHub“) gibt die drei Werte aus, die in `includes/catalog_defaults.php` von DeskHub eingetragen werden: Katalog-Adresse für Plugins, Katalog-Adresse für Themes und euren öffentlichen Schlüssel. Tragt sie vor dem Bauen eines Releases ein. Alle Installationen mit diesem Release laden dann nur Plugins, die ihr auf der Webseite veröffentlicht, also geprüft und signiert habt. Die Datei wird bei System-Updates durch die des Releases ersetzt, Kunden können die Vorgabe also nicht versehentlich verlieren. Dieser Ordner wird bei System-Updates nicht auf Kunden-Installationen kopiert.
|
**Geprüfte Plugins für eure Kunden:** `php einrichten.php` (und in der Verwaltung der Bereich „Einstellungen für DeskHub“) gibt die drei Werte aus, die in `includes/catalog_defaults.php` von DeskHub eingetragen werden: Katalog-Adresse für Plugins, Katalog-Adresse für Themes und euren öffentlichen Schlüssel. Tragt sie vor dem Bauen eines Releases ein. Alle Installationen mit diesem Release laden dann nur Plugins, die ihr auf der Webseite veröffentlicht, also geprüft und signiert habt. Die Datei wird bei System-Updates durch die des Releases ersetzt, Kunden können die Vorgabe also nicht versehentlich verlieren. Der Ordner `plugin-webseite/` gehört nicht zur DeskHub-Installation und wird nie auf Kunden-Server kopiert.
|
||||||
|
|
||||||
**Zugangsschutz des Katalogs:** `docker-build.sh` / `docker-build.ps1` erzeugen beim ersten Bauen einen Zugangsschlüssel (`schluessel/katalog-zugangsschluessel.txt`) und geben ihn am Ende aus. Tragt ihn in `includes/catalog_defaults.php` als `access_key` ein. Ohne diesen Schlüssel liefern Katalog, Downloads, Vorschaubilder und `publisher-key.php` nur `401`. `health.php` bleibt offen (Docker-Healthcheck). Nach dem Bauen: Image auf dem Server austauschen und ein DeskHub-Release mit dem eingetragenen Schlüssel veröffentlichen.
|
**Zugangsschutz des Katalogs:** `docker-build.sh` / `docker-build.ps1` erzeugen beim ersten Bauen einen Zugangsschlüssel (`schluessel/katalog-zugangsschluessel.txt`) und geben ihn am Ende aus. Tragt ihn in `includes/catalog_defaults.php` als `access_key` ein. Ohne diesen Schlüssel liefern Katalog, Downloads, Vorschaubilder und `publisher-key.php` nur `401`. `health.php` bleibt offen (Docker-Healthcheck). Nach dem Bauen: Image auf dem Server austauschen und ein DeskHub-Release mit dem eingetragenen Schlüssel veröffentlichen.
|
||||||
|
|
||||||
@@ -843,7 +891,7 @@ ok = hmac.compare_digest(expected, request.headers.get("X-DeskHub-Signature", ""
|
|||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# Interne Ordner und Dateien nie ausliefern
|
# Interne Ordner und Dateien nie ausliefern
|
||||||
location ~ ^/(config|database|includes|cron|backups|tests|tools|docs|desktop|mobile|theme-packages|vorschaubilder|plugin-webseite|app_downloads|\.git|\.gitea|\.github)(/|$) { return 404; }
|
location ~ ^/(config|database|includes|cron|backups|tests|tools|docs|app_downloads|\.git|\.gitea|\.github)(/|$) { return 404; }
|
||||||
location ~ ^/plugins/.+\.(php|sql|json|md|txt|log)$ { return 404; }
|
location ~ ^/plugins/.+\.(php|sql|json|md|txt|log)$ { return 404; }
|
||||||
location ~ ^/(version\.php|offen\.txt|install\.sh|install_windows\.bat)$ { return 404; }
|
location ~ ^/(version\.php|offen\.txt|install\.sh|install_windows\.bat)$ { return 404; }
|
||||||
location ~* \.(sqlite3?|db|sql|log|bak|old|md|sh|bat|ps1|pem|key|env|ini|lock)$ { return 404; }
|
location ~* \.(sqlite3?|db|sql|log|bak|old|md|sh|bat|ps1|pem|key|env|ini|lock)$ { return 404; }
|
||||||
@@ -851,6 +899,8 @@ ok = hmac.compare_digest(expected, request.headers.get("X-DeskHub-Signature", ""
|
|||||||
location ~* ^/uploads/.*\.(php\d?|phtml|phar|html?|xhtml|svgz?|xml|js)$ { return 403; }
|
location ~* ^/uploads/.*\.(php\d?|phtml|phar|html?|xhtml|svgz?|xml|js)$ { return 403; }
|
||||||
# Nach der Installation: Installationsassistent sperren
|
# Nach der Installation: Installationsassistent sperren
|
||||||
location ^~ /install/ { return 404; }
|
location ^~ /install/ { return 404; }
|
||||||
|
# In den PHP-Block (location ~ \.php$): Anmelde-Header der Apps an PHP weiterreichen
|
||||||
|
# fastcgi_param HTTP_AUTHORIZATION $http_authorization;
|
||||||
```
|
```
|
||||||
|
|
||||||
Diese Regeln müssen **vor** dem `location ~ \.php$`-Block stehen, weil nginx den ersten passenden Regex-Block nimmt. Danach in der **Sicherheitsprüfung** kontrollieren, dass alles gesperrt ist.
|
Diese Regeln müssen **vor** dem `location ~ \.php$`-Block stehen, weil nginx den ersten passenden Regex-Block nimmt. Danach in der **Sicherheitsprüfung** kontrollieren, dass alles gesperrt ist.
|
||||||
@@ -867,7 +917,7 @@ ok = hmac.compare_digest(expected, request.headers.get("X-DeskHub-Signature", ""
|
|||||||
4. **`app_key`** in `config/config.php` setzen (der Installer tut das) und nie ändern. Die Datei nach der Installation auf nur lesbar stellen (`chmod 640`).
|
4. **`app_key`** in `config/config.php` setzen (der Installer tut das) und nie ändern. Die Datei nach der Installation auf nur lesbar stellen (`chmod 640`).
|
||||||
5. **Ordner `install/` löschen**, **PHP-Fehleranzeige abschalten** (`display_errors = Off`), **Zwei-Faktor-Anmeldung** für alle Administratoren aktivieren (Profil → Sicherheit).
|
5. **Ordner `install/` löschen**, **PHP-Fehleranzeige abschalten** (`display_errors = Off`), **Zwei-Faktor-Anmeldung** für alle Administratoren aktivieren (Profil → Sicherheit).
|
||||||
6. **Backups** einrichten und prüfen (siehe [Backup](#backup)), den Ordner `backups/` nie öffentlich machen.
|
6. **Backups** einrichten und prüfen (siehe [Backup](#backup)), den Ordner `backups/` nie öffentlich machen.
|
||||||
7. **Eigenes Repository:** `config/config.php`, die Datenbank, Backups und Uploads gehören nie in ein Git-Repository. Die mitgelieferte `.gitignore` schließt sie aus.
|
7. **Eigenes Repository:** `config/config.php`, die Datenbank, Backups und Uploads gehören nie in ein Git-Repository. Die mitgelieferte `.gitignore` schließt sie aus. War eine `config/config.php` schon eingecheckt, entfernt sie aus dem Repository (`git rm --cached config/config.php`): Sonst landet eine fremde Konfiguration auf dem Server, und DeskHub arbeitet mit alten Daten statt mit denen aus dem Installer. Der Installer ersetzt eine vorhandene alte `config.php` zwar, prüft die geschriebene Datei und meldet, wenn PHP noch eine ältere Version aus dem Zwischenspeicher liefert, besser ist aber, sie gar nicht erst auszuliefern.
|
||||||
8. Updates installieren: Sicherheitsverbesserungen kommen mit neuen Versionen.
|
8. Updates installieren: Sicherheitsverbesserungen kommen mit neuen Versionen.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -883,6 +933,7 @@ Außerdem erstellt DeskHub automatisch ein Backup vor jedem System-Update, jedem
|
|||||||
|
|
||||||
**Prüfen und Wiederherstellen:**
|
**Prüfen und Wiederherstellen:**
|
||||||
|
|
||||||
|
- **Automatische Prüfung:** Jedes neue Backup wird sofort nach dem Erstellen geprüft (manuell, per Zeitplan, vor Updates, vor Plugin-Aktivierungen). Noch nie geprüfte Backups und solche, deren letzte Prüfung über 7 Tage her ist, werden im Hintergrund nachgeprüft, damit auch spätere Beschädigungen auffallen. Das Ergebnis steht bei jeder Sicherung in der Liste („✔ geprüft“ / „✖ defekt“). Ein defektes Backup meldet DeskHub zusätzlich per E-Mail an die Admin-Adresse. Von Hand prüfen musst du nichts; „Prüfen“ gibt es weiter für den Fall, dass du sofort nachsehen willst.
|
||||||
- Jedes Backup enthält einen konsistenten Datenbank-Schnappschuss (SQLite) bzw. einen SQL-Dump (MySQL) und eine Inhaltsliste mit Prüfsummen.
|
- Jedes Backup enthält einen konsistenten Datenbank-Schnappschuss (SQLite) bzw. einen SQL-Dump (MySQL) und eine Inhaltsliste mit Prüfsummen.
|
||||||
- **✔️ Prüfen** kontrolliert ein Backup, ohne etwas zu verändern. Nach jedem manuellen und automatischen Backup passiert das von selbst; schlägt es fehl, erscheint im Adminportal eine Störungsmeldung („Backup defekt“).
|
- **✔️ Prüfen** kontrolliert ein Backup, ohne etwas zu verändern. Nach jedem manuellen und automatischen Backup passiert das von selbst; schlägt es fehl, erscheint im Adminportal eine Störungsmeldung („Backup defekt“).
|
||||||
- **♻️ Wiederherstellen** spielt Datenbank und (wählbar) Uploads, Plugins und App-Downloads zurück. Davor wird automatisch ein Sicherheits-Backup des aktuellen Stands erstellt. Zur Bestätigung gebt ihr `WIEDERHERSTELLEN` ein. Programmcode und `config.php` werden nie überschrieben. Ein Backup einer anderen Datenbankart (SQLite ↔ MySQL) wird abgelehnt.
|
- **♻️ Wiederherstellen** spielt Datenbank und (wählbar) Uploads, Plugins und App-Downloads zurück. Davor wird automatisch ein Sicherheits-Backup des aktuellen Stands erstellt. Zur Bestätigung gebt ihr `WIEDERHERSTELLEN` ein. Programmcode und `config.php` werden nie überschrieben. Ein Backup einer anderen Datenbankart (SQLite ↔ MySQL) wird abgelehnt.
|
||||||
@@ -891,7 +942,7 @@ Außerdem erstellt DeskHub automatisch ein Backup vor jedem System-Update, jedem
|
|||||||
|
|
||||||
## Automatische Tests (CI)
|
## Automatische Tests (CI)
|
||||||
|
|
||||||
`php tests/run_all.php` führt alle PHP-Tests nacheinander aus (Exit-Code 1 bei Fehlern); mit Argument nur passende Tests, z. B. `php tests/run_all.php plugin`. Die Workflow-Datei `.gitea/workflows/tests.yml` startet bei jedem Push, jeder Pull-Request und nachts um 03:17 UTC: PHP 8.2 (Syntax und alle Tests), PHP 8.4 (nur Warnung), Windows-App-Oberflächentests (`npm test` unter xvfb), Android-Tests (`./gradlew testDebugUnitTest`) und der Bau des Docker-Images der Plugin-Webseite samt Zugangsschutz-Prüfung. Für GitHub die Datei nach `.github/workflows/` kopieren. Der Runner braucht für den Docker-Job Docker, für den Android-Job das Android-SDK.
|
Im Ordner `DeskHub-Webseite/` führt `php tests/run_all.php` alle PHP-Tests nacheinander aus (Exit-Code 1 bei Fehlern); mit Argument nur passende Tests, z. B. `php tests/run_all.php plugin`. Die Tests für Plugin-Webseite und Plugin-Quellen suchen ihre Ordner neben `DeskHub-Webseite/` (`plugin-webseite/`, `Plugins/`); abweichende Orte lassen sich mit `DESKHUB_WEBSITE_DIR` und `DESKHUB_PLUGIN_SRC` angeben, fehlt ein Ordner, werden diese Tests übersprungen. Die Workflow-Datei `.gitea/workflows/tests.yml` im Projektordner startet bei jedem Push, jeder Pull-Request und nachts um 03:17 UTC: PHP 8.2 (Syntax und alle Tests), PHP 8.4 (nur Warnung), Windows-App-Oberflächentests (`npm test` unter xvfb), Android-Tests (`./gradlew testDebugUnitTest`) und der Bau des Docker-Images der Plugin-Webseite samt Zugangsschutz-Prüfung. Für GitHub die Datei nach `.github/workflows/` kopieren (die Pfade im Workflow gelten für diese Ordnerstruktur). Der Runner braucht für den Docker-Job Docker, für den Android-Job das Android-SDK.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -903,4 +954,5 @@ Außerdem erstellt DeskHub automatisch ein Backup vor jedem System-Update, jedem
|
|||||||
- **Rotes oder gelbes Banner oben:** Das ist eine Störungsmeldung der Überwachung, siehe [Überwachung und Störungsmeldungen](#überwachung-und-störungsmeldungen).
|
- **Rotes oder gelbes Banner oben:** Das ist eine Störungsmeldung der Überwachung, siehe [Überwachung und Störungsmeldungen](#überwachung-und-störungsmeldungen).
|
||||||
- **„Datenbank veraltet“:** Unter System-Update auf „Datenbank aktualisieren“ klicken.
|
- **„Datenbank veraltet“:** Unter System-Update auf „Datenbank aktualisieren“ klicken.
|
||||||
- **Ein Plugin macht Probleme:** Unter Plugins deaktivieren. Lässt sich die Seite nicht mehr öffnen, hilft die Datei `plugins/.disabled`.
|
- **Ein Plugin macht Probleme:** Unter Plugins deaktivieren. Lässt sich die Seite nicht mehr öffnen, hilft die Datei `plugins/.disabled`.
|
||||||
|
- **Die App meldet „Ein gültiges Bearer-Token ist erforderlich“:** Der Webserver reicht den Anmelde-Header der App nicht an PHP weiter (typisch bei Apache mit PHP-FPM/FastCGI, z. B. Plesk). Anmelden funktioniert noch, jede weitere Anfrage scheitert. Die mitgelieferte `.htaccess` enthält die nötigen Regeln (neue Dateien hochladen). Auf **nginx** gehört in den PHP-Block `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`. Zusätzlich senden die Apps (ab dieser Version) den Wert als `X-DeskHub-Authorization`, der auf jedem Hoster ankommt. Prüfen lässt sich das mit einem Unsinns-Token: Antwortet `/api/staff/v1/dashboard.php` mit „Die Anmeldung ist abgelaufen“, kommt der Header an; antwortet sie mit „Ein gültiges Bearer-Token ist erforderlich“, nicht.
|
||||||
- **Die App zeigt „Keine Verbindung“:** Erreichbarkeit der Seite im Browser prüfen. Sobald der Server wieder antwortet, verschwindet das Banner von selbst.
|
- **Die App zeigt „Keine Verbindung“:** Erreichbarkeit der Seite im Browser prüfen. Sobald der Server wieder antwortet, verschwindet das Banner von selbst.
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ if ($departmentIds !== null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional: nur Textbausteine für die Abteilung eines Tickets (diese Abteilung plus die für alle Abteilungen)
|
||||||
|
$onlyDept = isset($_GET['departmentId']) && ctype_digit((string) $_GET['departmentId']) ? (int) $_GET['departmentId'] : null;
|
||||||
|
if ($onlyDept !== null) {
|
||||||
|
if ($departmentIds !== null && !in_array($onlyDept, $departmentIds, true)) {
|
||||||
|
api_error(403, 'FORBIDDEN', 'Kein Zugriff auf diese Abteilung.');
|
||||||
|
}
|
||||||
|
$deptFilter = ' AND (cr.department_id IS NULL OR cr.department_id = ?)';
|
||||||
|
$deptParams = [$onlyDept];
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT cr.id, cr.title, cr.body, cr.department_id
|
"SELECT cr.id, cr.title, cr.body, cr.department_id
|
||||||
FROM canned_responses cr
|
FROM canned_responses cr
|
||||||
|
|||||||
@@ -66,14 +66,20 @@ function api_require_https(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Anmeldung der Apps. Viele Hoster (Apache mit PHP-FPM/FastCGI, z. B. Plesk) reichen den Header "Authorization" nicht an PHP weiter.
|
||||||
|
* Deshalb senden die Apps denselben Wert zusätzlich als "X-DeskHub-Authorization", der überall ankommt; hier gilt der erste vorhandene.
|
||||||
|
*/
|
||||||
function api_authorization_header(): string
|
function api_authorization_header(): string
|
||||||
{
|
{
|
||||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
||||||
if ($header === '' && function_exists('getallheaders')) {
|
if (trim((string) $header) === '') $header = $_SERVER['HTTP_X_DESKHUB_AUTHORIZATION'] ?? '';
|
||||||
$headers = getallheaders();
|
if (trim((string) $header) === '' && function_exists('getallheaders')) {
|
||||||
$header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
foreach (getallheaders() as $name => $value) {
|
||||||
|
if (in_array(strtolower((string) $name), ['authorization', 'x-deskhub-authorization'], true) && trim((string) $value) !== '') { $header = $value; break; }
|
||||||
}
|
}
|
||||||
return trim($header);
|
}
|
||||||
|
return trim((string) $header);
|
||||||
}
|
}
|
||||||
|
|
||||||
function api_bearer_token(): ?string
|
function api_bearer_token(): ?string
|
||||||
@@ -204,11 +210,27 @@ function api_find_ticket(PDO $pdo, array $staff, string $number): array
|
|||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adresse des ersten Bildes in einem HTML-Text (absolut, nur http/https); leer, wenn keines vorhanden ist. */
|
||||||
|
function api_first_image_url(?string $html): string
|
||||||
|
{
|
||||||
|
if (!preg_match('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1/is', (string) $html, $m)) return '';
|
||||||
|
$src = trim(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
if (str_starts_with($src, '//')) $src = 'https:' . $src;
|
||||||
|
elseif (str_starts_with($src, '/')) $src = base_url() . $src;
|
||||||
|
return preg_match('#^https?://#i', $src) ? $src : '';
|
||||||
|
}
|
||||||
|
|
||||||
function api_plain_text(?string $html): string
|
function api_plain_text(?string $html): string
|
||||||
{
|
{
|
||||||
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
|
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
|
||||||
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
|
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
|
||||||
return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
// Weitere Blöcke (div, Listen, Tabellenzeilen, Überschriften …) enden ebenfalls mit einem Zeilenumbruch, Listenpunkte bekommen einen Punkt
|
||||||
|
$text = preg_replace('/<li\b[^>]*>/i', '• ', $text);
|
||||||
|
$text = preg_replace('/<\/(div|li|tr|h[1-6]|blockquote|pre|ul|ol|table)\s*>/i', "\n", $text);
|
||||||
|
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$text = preg_replace('/[ \t]+\n/', "\n", $text); // Leerzeichen am Zeilenende
|
||||||
|
$text = preg_replace('/\n{3,}/', "\n\n", $text); // höchstens eine Leerzeile
|
||||||
|
return trim($text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function api_ticket_summary(array $ticket): array
|
function api_ticket_summary(array $ticket): array
|
||||||
@@ -247,7 +269,7 @@ function api_ticket_summary(array $ticket): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus): int
|
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int
|
||||||
{
|
{
|
||||||
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
|
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
|
||||||
api_require_permission($staff, $permission, (int) $ticket['department_id']);
|
api_require_permission($staff, $permission, (int) $ticket['department_id']);
|
||||||
@@ -331,12 +353,8 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$isNote) {
|
if (!$isNote) {
|
||||||
$signature = trim((string) ($staff['signature'] ?? ''));
|
// Signatur: optional vom Aufrufer gewählt (own, dept, none), sonst eigene, ersatzweise die der Abteilung
|
||||||
if ($signature === '') {
|
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $signatureChoice);
|
||||||
$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 = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
|
||||||
$collabStmt->execute([$ticket['id']]);
|
$collabStmt->execute([$ticket['id']]);
|
||||||
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
|
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
|
|||||||
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
|
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
|
||||||
}
|
}
|
||||||
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|
||||||
|| !rate_limit_check('mobile_staff_login_email', $email, 5, 15)) {
|
|| !rate_limit_check('mobile_staff_login_email', strtolower($email), 5, 15)) {
|
||||||
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
|
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$pdo = get_db();
|
$pdo = get_db();
|
||||||
$stmt = $pdo->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
|
$stmt = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||||
$stmt->execute([$email]);
|
$stmt->execute([$email]);
|
||||||
$staff = $stmt->fetch();
|
$staff = $stmt->fetch();
|
||||||
if (!$staff || !password_verify($password, $staff['password_hash'])) {
|
if (!$staff || !password_verify($password, $staff['password_hash'])) {
|
||||||
@@ -33,7 +33,7 @@ if (!empty($staff['totp_enabled'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rate_limit_reset('mobile_staff_login_email', $email);
|
rate_limit_reset('mobile_staff_login_email', strtolower($email));
|
||||||
rate_limit_reset('mobile_staff_login_ip', client_ip());
|
rate_limit_reset('mobile_staff_login_ip', client_ip());
|
||||||
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
|
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
|
||||||
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
|
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
$pdo = get_db();
|
$pdo = get_db();
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
|
||||||
$stmt->execute([$email]);
|
$stmt->execute([$email]);
|
||||||
if ($stmt->fetch()) {
|
if ($stmt->fetch()) {
|
||||||
$errors[] = 'Für diese E-Mail-Adresse existiert bereits ein Konto.';
|
$errors[] = 'Für diese E-Mail-Adresse existiert bereits ein Konto.';
|
||||||
|
|||||||
+231
-83
@@ -9,8 +9,19 @@ $helpTopics = help_topic_options($pdo, true);
|
|||||||
$useHelpTopics = count($helpTopics) > 0;
|
$useHelpTopics = count($helpTopics) > 0;
|
||||||
// Ohne Hilfe-Themen gibt es keine Themenauswahl auf dem Formular, daher nur themenunabhängige Felder anzeigen.
|
// Ohne Hilfe-Themen gibt es keine Themenauswahl auf dem Formular, daher nur themenunabhängige Felder anzeigen.
|
||||||
$customFields = $useHelpTopics
|
$customFields = $useHelpTopics
|
||||||
? $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll()
|
? $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll()
|
||||||
: $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND help_topic_id IS NULL ORDER BY sort_order, label')->fetchAll();
|
: $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL AND help_topic_id IS NULL ORDER BY sort_order, label')->fetchAll();
|
||||||
|
|
||||||
|
// Abteilungen mit eigenem Formular: Texte und Felder (erscheinen nur, wenn der Kunde diese Abteilung wählt)
|
||||||
|
$deptForms = [];
|
||||||
|
foreach ($departments as $d) {
|
||||||
|
$df = department_form($pdo, (int) $d['id']);
|
||||||
|
if ($df) {
|
||||||
|
$deptForms[(int) $d['id']] = $df;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$topicDept = [];
|
||||||
|
foreach ($helpTopics as $t) $topicDept[(int) $t['id']] = (int) $t['department_id'];
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
@@ -62,6 +73,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (!in_array($prioId, array_column($priorities, 'id'))) $errors[] = 'Bitte wählen Sie eine Priorität.';
|
if (!in_array($prioId, array_column($priorities, 'id'))) $errors[] = 'Bitte wählen Sie eine Priorität.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigenes Formular der gewählten Abteilung: ersetzt Betreff-/Feld-/Anhang-Vorgaben des Standardformulars
|
||||||
|
$ownForm = $deptId ? ($deptForms[(int) $deptId] ?? null) : null;
|
||||||
|
if ($ownForm && !$ownForm['subject_show']) $subject = 'Anfrage ' . $ownForm['department_name'];
|
||||||
|
if ($ownForm && !$ownForm['message_show']) {
|
||||||
|
// Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder
|
||||||
|
$lines = [];
|
||||||
|
foreach ($ownForm['fields'] as $ff) {
|
||||||
|
$raw = $ff['field_type'] === 'checkbox' ? (!empty($_POST['custom_' . $ff['id']]) ? 'Ja' : 'Nein') : trim((string) ($_POST['custom_' . $ff['id']] ?? ''));
|
||||||
|
if ($raw !== '') $lines[] = $ff['label'] . ': ' . $raw;
|
||||||
|
}
|
||||||
|
$message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben';
|
||||||
|
}
|
||||||
|
|
||||||
if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.';
|
if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.';
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
||||||
if ($subject === '') $errors[] = 'Bitte geben Sie einen Betreff an.';
|
if ($subject === '') $errors[] = 'Bitte geben Sie einen Betreff an.';
|
||||||
@@ -69,19 +93,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
// Individuelle Felder validieren (nur Felder, die zum gewählten Hilfe-Thema gehören oder themenunabhängig sind)
|
// Individuelle Felder validieren (nur Felder, die zum gewählten Hilfe-Thema gehören oder themenunabhängig sind)
|
||||||
$customValues = []; // field id => submitted value
|
$customValues = []; // field id => submitted value
|
||||||
foreach ($customFields as $f) {
|
foreach ($ownForm ? $ownForm['fields'] : $customFields as $f) {
|
||||||
if ($f['help_topic_id'] !== null && (int) $f['help_topic_id'] !== (int) $helpTopicId) continue;
|
if (!$ownForm && ($f['department_id'] !== null || ($f['help_topic_id'] !== null && (int) $f['help_topic_id'] !== (int) $helpTopicId))) continue;
|
||||||
$fieldName = 'custom_' . $f['id'];
|
$fieldName = 'custom_' . $f['id'];
|
||||||
$val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fieldName]) ? '1' : '0') : trim($_POST[$fieldName] ?? '');
|
$val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fieldName]) ? '1' : '0') : trim($_POST[$fieldName] ?? '');
|
||||||
if ($f['is_required'] && $f['field_type'] !== 'checkbox' && $val === '') {
|
if ($f['is_required'] && $f['field_type'] !== 'checkbox' && $val === '') {
|
||||||
$errors[] = 'Bitte füllen Sie das Feld „' . $f['label'] . '“ aus.';
|
$errors[] = 'Bitte füllen Sie das Feld „' . $f['label'] . '“ aus.';
|
||||||
}
|
}
|
||||||
|
if ($f['is_required'] && $f['field_type'] === 'checkbox' && $val !== '1') {
|
||||||
|
$errors[] = 'Bitte bestätigen Sie „' . $f['label'] . '“.';
|
||||||
|
}
|
||||||
|
if (in_array($f['field_type'], ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options'])), true)) {
|
||||||
|
$errors[] = 'Bitte wählen Sie bei „' . $f['label'] . '“ eine der angebotenen Möglichkeiten.';
|
||||||
|
$val = '';
|
||||||
|
}
|
||||||
$customValues[$f['id']] = $val;
|
$customValues[$f['id']] = $val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei-Upload (optional, ein Anhang)
|
// Datei-Upload (optional, ein Anhang)
|
||||||
$uploadInfo = null;
|
$uploadInfo = null;
|
||||||
if (!$errors && !empty($_FILES['attachment']['name'])) {
|
if (!$errors && (!$ownForm || $ownForm['attachment_show']) && !empty($_FILES['attachment']['name'])) {
|
||||||
$uploadInfo = handle_ticket_upload($_FILES['attachment']);
|
$uploadInfo = handle_ticket_upload($_FILES['attachment']);
|
||||||
if (isset($uploadInfo['error'])) {
|
if (isset($uploadInfo['error'])) {
|
||||||
$errors[] = $uploadInfo['error'];
|
$errors[] = $uploadInfo['error'];
|
||||||
@@ -186,6 +217,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules']));
|
log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules']));
|
||||||
}
|
}
|
||||||
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
|
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
|
||||||
|
$closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult);
|
||||||
|
|
||||||
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
|
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
|
||||||
$deptStmt->execute([$deptId]);
|
$deptStmt->execute([$deptId]);
|
||||||
@@ -198,6 +230,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'department_id' => $deptId,
|
'department_id' => $deptId,
|
||||||
'department_name' => $dept['name'] ?? null,
|
'department_name' => $dept['name'] ?? null,
|
||||||
];
|
];
|
||||||
|
if (!$closedByRule) {
|
||||||
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
|
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
|
||||||
notify_staff_new_ticket($ticketForNotify);
|
notify_staff_new_ticket($ticketForNotify);
|
||||||
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
||||||
@@ -206,6 +239,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
push_new_ticket($ticketForNotify);
|
push_new_ticket($ticketForNotify);
|
||||||
}
|
}
|
||||||
notify_customer_ticket_created($ticketForNotify);
|
notify_customer_ticket_created($ticketForNotify);
|
||||||
|
}
|
||||||
|
|
||||||
$_SESSION['guest_ticket_access'][$ticketNumber] = true;
|
$_SESSION['guest_ticket_access'][$ticketNumber] = true;
|
||||||
flash('success', "Ihr Ticket #{$ticketNumber} wurde erstellt. Wir melden uns so schnell wie möglich.");
|
flash('success', "Ihr Ticket #{$ticketNumber} wurde erstellt. Wir melden uns so schnell wie möglich.");
|
||||||
@@ -231,6 +265,64 @@ require __DIR__ . '/includes/header_customer.php';
|
|||||||
<div class="flash error"><?= e($err) ?></div>
|
<div class="flash error"><?= e($err) ?></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php $renderField = function (array $f): void {
|
||||||
|
$fieldName = 'custom_' . $f['id'];
|
||||||
|
$posted = $_POST[$fieldName] ?? '';
|
||||||
|
?>
|
||||||
|
<?php $__hint = ($f['field_type'] !== 'checkbox' && !empty($f['help_text'])) ? form_help_html($f['help_text'], (string) $f['label']) : ''; ?>
|
||||||
|
<div class="custom-field-group fw-<?= e($f['form_width'] ?? '') ?: 'full' ?>" data-topic="<?= $f['help_topic_id'] !== null ? (int) $f['help_topic_id'] : '' ?>" data-dept="<?= $f['department_id'] !== null ? (int) $f['department_id'] : '' ?>">
|
||||||
|
<label for="<?= $fieldName ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' <span style="color:var(--danger)">*</span>' : '' ?></label>
|
||||||
|
<?php if ($f['field_type'] === 'textarea'): ?>
|
||||||
|
<textarea id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
|
||||||
|
<?php elseif ($f['field_type'] === 'select'): ?>
|
||||||
|
<select id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||||
|
<option value="">– bitte wählen –</option>
|
||||||
|
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $opt): $opt = trim($opt); if ($opt === '') continue; ?>
|
||||||
|
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php elseif ($f['field_type'] === 'radio'): ?>
|
||||||
|
<div style="margin-top:4px<?= !empty($f['radio_inline']) ? ';display:flex;flex-wrap:wrap;gap:4px 20px' : '' ?>">
|
||||||
|
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $ri => $opt): $opt = trim($opt); if ($opt === '') continue; ?>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin:4px 0">
|
||||||
|
<input type="radio" id="<?= $fieldName ?>_<?= (int) $ri ?>" name="<?= $fieldName ?>" value="<?= e($opt) ?>" style="width:auto" <?= $f['is_required'] ? 'required' : '' ?> <?= $posted === $opt ? 'checked' : '' ?>>
|
||||||
|
<span><?= e($opt) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($f['field_type'] === 'checkbox'): ?>
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;margin-top:4px">
|
||||||
|
<input type="checkbox" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="1" style="width:auto;margin-top:3px" <?= $f['is_required'] ? 'required' : '' ?> <?= !empty($posted) ? 'checked' : '' ?>>
|
||||||
|
<span><?= !empty($f['help_text']) ? form_help_html($f['help_text'], (string) $f['label']) : e($f['label']) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php elseif ($f['field_type'] === 'number'): ?>
|
||||||
|
<input type="number" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||||
|
<?php elseif ($f['field_type'] === 'date'): ?>
|
||||||
|
<input type="date" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||||
|
<?php else: ?>
|
||||||
|
<input type="text" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($__hint !== ''): ?><p class="muted" style="font-size:.8rem;margin:4px 0 0"><?= $__hint ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php }; ?>
|
||||||
|
|
||||||
|
<?php $renderSlot = function (string $section, bool $wrap = true) use ($deptForms, $renderField): void {
|
||||||
|
$list = [];
|
||||||
|
foreach ($deptForms as $df) foreach ($df['fields'] as $ff) if ($ff['_section'] === $section) $list[] = $ff;
|
||||||
|
if (!$list) return;
|
||||||
|
if ($wrap) echo '<div class="dept-fields">';
|
||||||
|
foreach ($list as $ff) $renderField($ff);
|
||||||
|
if ($wrap) echo '</div>';
|
||||||
|
}; ?>
|
||||||
|
<style>
|
||||||
|
.dept-fields{display:flex;flex-wrap:wrap;gap:14px 16px;margin-bottom:14px}
|
||||||
|
.dept-fields>*{flex:1 1 100%;min-width:0}
|
||||||
|
.dept-fields>.fw-half{flex:0 0 calc(50% - 8px)}
|
||||||
|
.dept-fields>.fw-third{flex:0 0 calc(33.333% - 11px)}
|
||||||
|
.dept-fields>.fw-twothirds{flex:0 0 calc(66.666% - 6px)}
|
||||||
|
.dept-fields input[type=text],.dept-fields input[type=email],.dept-fields input[type=number],.dept-fields input[type=date],.dept-fields select,.dept-fields textarea{width:100%;box-sizing:border-box;margin-bottom:0}
|
||||||
|
@media (max-width:640px){.dept-fields>.fw-half,.dept-fields>.fw-third,.dept-fields>.fw-twothirds{flex:1 1 100%}}
|
||||||
|
</style>
|
||||||
<div class="card ticket-create-card">
|
<div class="card ticket-create-card">
|
||||||
<form method="post" enctype="multipart/form-data" class="ticket-create-form">
|
<form method="post" enctype="multipart/form-data" class="ticket-create-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
@@ -239,33 +331,16 @@ require __DIR__ . '/includes/header_customer.php';
|
|||||||
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
<input type="text" name="website" tabindex="-1" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!$user): ?>
|
|
||||||
<!-- Schritt 1: Kontaktdaten -->
|
|
||||||
<div class="ticket-form-section">
|
|
||||||
<p class="ticket-section-label"><span class="ticket-section-number">1</span>Ihre Kontaktdaten</p>
|
|
||||||
<div class="field-row">
|
|
||||||
<div>
|
|
||||||
<label for="name">Name <span style="color:var(--danger)">*</span></label>
|
|
||||||
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" placeholder="Max Mustermann" required autofocus>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="email">E-Mail-Adresse <span style="color:var(--danger)">*</span></label>
|
|
||||||
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="max@beispiel.de" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="ticket-form-hint">
|
|
||||||
<a href="/register.php">Kostenloses Konto erstellen</a> um alle Ihre Tickets im Überblick zu behalten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Schritt 2: Anliegen -->
|
<!-- Schritt 2: Anliegen -->
|
||||||
<div class="ticket-form-section">
|
<div class="ticket-form-section">
|
||||||
<p class="ticket-section-label"><span class="ticket-section-number">2</span>Ihr Anliegen</p>
|
<p class="ticket-section-label"><span class="ticket-section-number">2</span><span id="issue-title">Ihr Anliegen</span></p>
|
||||||
|
|
||||||
|
<div class="dept-fields">
|
||||||
|
<?php $renderSlot('topic_top', false); ?>
|
||||||
|
<div>
|
||||||
<?php if ($useHelpTopics): ?>
|
<?php if ($useHelpTopics): ?>
|
||||||
<label for="help_topic_id">Kategorie <span style="color:var(--danger)">*</span></label>
|
<label for="help_topic_id">Kategorie <span style="color:var(--danger)">*</span></label>
|
||||||
<select id="help_topic_id" name="help_topic_id" required onchange="updateTopicFieldVisibility()" style="margin-bottom:14px">
|
<select id="help_topic_id" name="help_topic_id" required onchange="updateTopicFieldVisibility()">
|
||||||
<option value="">– bitte wählen –</option>
|
<option value="">– bitte wählen –</option>
|
||||||
<?php foreach ($helpTopics as $t): ?>
|
<?php foreach ($helpTopics as $t): ?>
|
||||||
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
|
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
|
||||||
@@ -292,52 +367,82 @@ require __DIR__ . '/includes/header_customer.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php $renderSlot('topic', false); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="subject">Betreff <span style="color:var(--danger)">*</span></label>
|
<?php if (!$user): ?>
|
||||||
<input type="text" id="subject" name="subject" value="<?= e($_POST['subject'] ?? '') ?>" placeholder="Kurze Zusammenfassung Ihres Problems" required style="margin-bottom:14px">
|
<!-- Schritt 1: Kontaktdaten -->
|
||||||
|
<div class="ticket-form-section">
|
||||||
|
<p class="ticket-section-label"><span class="ticket-section-number">1</span><span class="contact-title">Ihre Kontaktdaten</span></p>
|
||||||
|
<div class="dept-fields">
|
||||||
|
<?php $renderSlot('contact_top', false); ?>
|
||||||
|
<div class="fw-half">
|
||||||
|
<label for="name">Name <span style="color:var(--danger)">*</span></label>
|
||||||
|
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" placeholder="Max Mustermann" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="fw-half">
|
||||||
|
<label for="email">E-Mail-Adresse <span style="color:var(--danger)">*</span></label>
|
||||||
|
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="max@beispiel.de" required>
|
||||||
|
</div>
|
||||||
|
<?php $renderSlot('contact', false); ?>
|
||||||
|
</div>
|
||||||
|
<p class="ticket-form-hint">
|
||||||
|
<a href="/register.php">Kostenloses Konto erstellen</a> um alle Ihre Tickets im Überblick zu behalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="ticket-form-section" id="contact-extra" style="display:none">
|
||||||
|
<p class="ticket-section-label"><span class="ticket-section-number">1</span><span class="contact-title">Ihre Kontaktdaten</span></p>
|
||||||
|
<?php $renderSlot('contact_top'); ?>
|
||||||
|
<?php $renderSlot('contact'); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<label for="message">Beschreibung <span style="color:var(--danger)">*</span></label>
|
<!-- Schritt 3: Details -->
|
||||||
|
<div class="ticket-form-section" id="details-section">
|
||||||
|
<p class="ticket-section-label"><span class="ticket-section-number"></span><span id="details-title">Ihr Anliegen</span></p>
|
||||||
|
<div id="dept-intro" style="display:none;margin-bottom:14px"><strong id="dept-title"></strong><p class="muted" id="dept-intro-text" style="margin:4px 0 0"></p></div>
|
||||||
|
|
||||||
|
<div class="dept-fields">
|
||||||
|
<?php $renderSlot('issue_top', false); ?>
|
||||||
|
|
||||||
|
<div id="subject-wrap">
|
||||||
|
<label for="subject"><span id="subject-label">Betreff</span> <span style="color:var(--danger)">*</span></label>
|
||||||
|
<input type="text" id="subject" name="subject" value="<?= e($_POST['subject'] ?? '') ?>" placeholder="Kurze Zusammenfassung Ihres Problems" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $renderSlot('issue_mid', false); ?>
|
||||||
|
|
||||||
|
<div id="message-wrap">
|
||||||
|
<label for="message"><span id="message-label">Beschreibung</span> <span style="color:var(--danger)">*</span></label>
|
||||||
<textarea id="message" name="message" required placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…" style="min-height:140px"><?= e($_POST['message'] ?? '') ?></textarea>
|
<textarea id="message" name="message" required placeholder="Beschreiben Sie Ihr Anliegen so detailliert wie möglich…" style="min-height:140px"><?= e($_POST['message'] ?? '') ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<?php $renderSlot('issue', false); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php if ($customFields): ?>
|
<?php if ($customFields): ?>
|
||||||
<div class="ticket-form-section">
|
<div class="ticket-form-section" id="custom-section">
|
||||||
<p class="ticket-section-label"><span class="ticket-section-number">3</span>Zusätzliche Angaben</p>
|
<p class="ticket-section-label"><span class="ticket-section-number"></span>Zusätzliche Angaben</p>
|
||||||
<?php foreach ($customFields as $f):
|
<div class="dept-fields"><?php foreach ($customFields as $f) { $renderField($f); } ?></div>
|
||||||
$fieldName = 'custom_' . $f['id'];
|
|
||||||
$posted = $_POST[$fieldName] ?? '';
|
|
||||||
?>
|
|
||||||
<div class="custom-field-group" data-topic="<?= $f['help_topic_id'] !== null ? (int) $f['help_topic_id'] : '' ?>">
|
|
||||||
<label for="<?= $fieldName ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' <span style="color:var(--danger)">*</span>' : '' ?></label>
|
|
||||||
<?php if ($f['field_type'] === 'textarea'): ?>
|
|
||||||
<textarea id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
|
|
||||||
<?php elseif ($f['field_type'] === 'select'): ?>
|
|
||||||
<select id="<?= $fieldName ?>" name="<?= $fieldName ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
|
||||||
<option value="">– bitte wählen –</option>
|
|
||||||
<?php foreach (preg_split('/\r?\n/', trim((string) $f['options'])) as $opt): $opt = trim($opt); if ($opt === '') continue; ?>
|
|
||||||
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<?php elseif ($f['field_type'] === 'checkbox'): ?>
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:4px">
|
|
||||||
<input type="checkbox" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="1" style="width:auto" <?= !empty($posted) ? 'checked' : '' ?>>
|
|
||||||
<span><?= e($f['label']) ?></span>
|
|
||||||
</label>
|
|
||||||
<?php elseif ($f['field_type'] === 'number'): ?>
|
|
||||||
<input type="number" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
|
||||||
<?php elseif ($f['field_type'] === 'date'): ?>
|
|
||||||
<input type="date" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
|
||||||
<?php else: ?>
|
|
||||||
<input type="text" id="<?= $fieldName ?>" name="<?= $fieldName ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php // Eigene Abschnitte der Abteilungsformulare (Abschnittsname frei wählbar)
|
||||||
|
foreach ($deptForms as $did => $df):
|
||||||
|
$groups = [];
|
||||||
|
foreach ($df['fields'] as $ff) if (!in_array($ff['_section'], ['contact', 'contact_top', 'issue', 'issue_top', 'issue_mid', 'topic', 'topic_top'], true)) $groups[$ff['_section_title']][] = $ff;
|
||||||
|
foreach ($groups as $title => $list): ?>
|
||||||
|
<div class="ticket-form-section dept-section" data-dept="<?= (int) $did ?>" style="display:none">
|
||||||
|
<p class="ticket-section-label"><span class="ticket-section-number"></span><?= e($title) ?></p>
|
||||||
|
<div class="dept-fields"><?php foreach ($list as $ff) $renderField($ff); ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; endforeach; ?>
|
||||||
|
|
||||||
<!-- Anhang -->
|
<!-- Anhang -->
|
||||||
<div class="ticket-form-section ticket-attachment-section">
|
<div class="ticket-form-section ticket-attachment-section" id="attachment-section">
|
||||||
<p class="ticket-section-label"><span class="ticket-section-number"><?= $customFields ? '4' : '3' ?></span>Anhang <span class="ticket-section-optional">(optional)</span></p>
|
<p class="ticket-section-label"><span class="ticket-section-number"></span>Anhang <span class="ticket-section-optional">(optional)</span></p>
|
||||||
<label for="attachment" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1.5px dashed var(--border);border-radius:var(--radius);cursor:pointer;font-size:.85rem;color:var(--muted);font-weight:400;transition:border-color .15s" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
|
<label for="attachment" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border:1.5px dashed var(--border);border-radius:var(--radius);cursor:pointer;font-size:.85rem;color:var(--muted);font-weight:400;transition:border-color .15s" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
<span id="att-label">Datei auswählen</span>
|
<span id="att-label">Datei auswählen</span>
|
||||||
@@ -347,32 +452,75 @@ require __DIR__ . '/includes/header_customer.php';
|
|||||||
|
|
||||||
<div class="ticket-form-actions">
|
<div class="ticket-form-actions">
|
||||||
<p class="ticket-form-hint">Pflichtfelder sind mit <span class="ticket-required">*</span> gekennzeichnet.</p>
|
<p class="ticket-form-hint">Pflichtfelder sind mit <span class="ticket-required">*</span> gekennzeichnet.</p>
|
||||||
<button type="submit" class="btn ticket-submit">Ticket erstellen</button>
|
<button type="submit" class="btn ticket-submit" id="submit-btn">Ticket erstellen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($useHelpTopics): ?>
|
|
||||||
<script>
|
<script>
|
||||||
function updateTopicFieldVisibility() {
|
// Formular passt sich der gewählten Abteilung an: Abteilungen mit eigenem Formular zeigen nur ihre Felder und Texte.
|
||||||
var select = document.getElementById('help_topic_id');
|
var DEPT_FORMS = <?= json_encode($deptForms ? array_map(fn($f) => array_diff_key($f, ['fields' => 1]), $deptForms) : new stdClass(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
var selectedTopic = select ? select.value : '';
|
var TOPIC_DEPT = <?= json_encode($topicDept ?: new stdClass()) ?>;
|
||||||
document.querySelectorAll('.custom-field-group').forEach(function (group) {
|
var DEFAULTS = { subjectLabel: 'Betreff', messageLabel: 'Beschreibung', hint: 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…', submit: 'Ticket erstellen' };
|
||||||
var groupTopic = group.getAttribute('data-topic');
|
function setShown(el, on) {
|
||||||
var matches = groupTopic === '' || groupTopic === selectedTopic;
|
if (!el) return;
|
||||||
group.style.display = matches ? '' : 'none';
|
el.style.display = on ? '' : 'none';
|
||||||
group.querySelectorAll('input, select, textarea').forEach(function (el) {
|
el.querySelectorAll('input, select, textarea').forEach(function (i) {
|
||||||
if (!matches) {
|
if (!on) { if (i.required) { i.dataset.wasRequired = '1'; i.required = false; } if (i.type !== 'hidden') i.disabled = true; }
|
||||||
if (el.required) { el.dataset.wasRequired = '1'; el.required = false; }
|
else { if (i.dataset.wasRequired === '1') i.required = true; i.disabled = false; }
|
||||||
} else if (el.dataset.wasRequired === '1') {
|
|
||||||
el.required = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', updateTopicFieldVisibility);
|
function applyDeptForm() {
|
||||||
|
var topicSel = document.getElementById('help_topic_id'), deptSel = document.getElementById('department_id');
|
||||||
|
var topic = topicSel ? topicSel.value : '';
|
||||||
|
var dept = topicSel ? (TOPIC_DEPT[topic] || '') : (deptSel ? deptSel.value : '');
|
||||||
|
var form = DEPT_FORMS[dept] || null;
|
||||||
|
var visible = 0, contactVisible = 0;
|
||||||
|
document.querySelectorAll('.custom-field-group').forEach(function (g) {
|
||||||
|
var gd = g.getAttribute('data-dept'), gt = g.getAttribute('data-topic');
|
||||||
|
var show = form ? gd === String(dept) : (gd === '' && (gt === '' || gt === topic));
|
||||||
|
setShown(g, show);
|
||||||
|
if (show && gd === '') visible++;
|
||||||
|
if (show && gd !== '' && g.closest('#contact-extra')) contactVisible++;
|
||||||
|
});
|
||||||
|
var sec = document.getElementById('custom-section');
|
||||||
|
if (sec) sec.style.display = visible > 0 ? '' : 'none';
|
||||||
|
document.querySelectorAll('.dept-fields').forEach(function (w) {
|
||||||
|
w.style.display = Array.prototype.some.call(w.children, function (c) { return c.style.display !== 'none'; }) ? '' : 'none';
|
||||||
|
});
|
||||||
|
var cx = document.getElementById('contact-extra');
|
||||||
|
if (cx) cx.style.display = contactVisible > 0 ? '' : 'none';
|
||||||
|
document.querySelectorAll('.dept-section').forEach(function (s) { s.style.display = (form && s.getAttribute('data-dept') === String(dept)) ? '' : 'none'; });
|
||||||
|
document.querySelectorAll('.contact-title').forEach(function (t) { t.textContent = form ? form.contact_title : 'Ihre Kontaktdaten'; });
|
||||||
|
document.getElementById('issue-title').textContent = form ? form.issue_title : 'Ihr Anliegen';
|
||||||
|
document.getElementById('details-title').textContent = form ? form.details_title : 'Ihr Anliegen'; document.getElementById('subject-label').textContent = form ? form.subject_label : DEFAULTS.subjectLabel;
|
||||||
|
document.getElementById('message-label').textContent = form ? form.message_label : DEFAULTS.messageLabel;
|
||||||
|
document.getElementById('message').placeholder = form ? form.message_hint : DEFAULTS.hint;
|
||||||
|
document.getElementById('submit-btn').textContent = form ? form.submit_label : DEFAULTS.submit;
|
||||||
|
setShown(document.getElementById('subject-wrap'), !form || form.subject_show);
|
||||||
|
setShown(document.getElementById('message-wrap'), !form || form.message_show);
|
||||||
|
[['subject-wrap', 'subject_width'], ['message-wrap', 'message_width']].forEach(function (p) {
|
||||||
|
var el = document.getElementById(p[0]);
|
||||||
|
['fw-full', 'fw-half', 'fw-third', 'fw-twothirds'].forEach(function (k) { el.classList.remove(k); });
|
||||||
|
el.classList.add('fw-' + ((form && form[p[1]]) || 'full'));
|
||||||
|
});
|
||||||
|
setShown(document.getElementById('attachment-section'), !form || form.attachment_show);
|
||||||
|
var has = form && (form.title || form.intro);
|
||||||
|
document.getElementById('dept-intro').style.display = has ? '' : 'none';
|
||||||
|
document.getElementById('dept-title').textContent = form ? form.title : '';
|
||||||
|
document.getElementById('dept-intro-text').textContent = form ? form.intro : '';
|
||||||
|
var num = 0;
|
||||||
|
document.querySelectorAll('.ticket-form-section').forEach(function (s) {
|
||||||
|
var n = s.querySelector('.ticket-section-number');
|
||||||
|
if (n && s.style.display !== 'none') n.textContent = ++num;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateTopicFieldVisibility() { applyDeptForm(); }
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var d = document.getElementById('department_id');
|
||||||
|
if (d) d.addEventListener('change', applyDeptForm);
|
||||||
|
applyDeptForm();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ $siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub');
|
|||||||
.print-footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #e2e8f0; font-size: 8.5pt; color: #94a3b8; display: flex; justify-content: space-between; }
|
.print-footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #e2e8f0; font-size: 8.5pt; color: #94a3b8; display: flex; justify-content: space-between; }
|
||||||
@media print { .no-print { display: none !important; } @page { margin: 15mm; } }
|
@media print { .no-print { display: none !important; } @page { margin: 15mm; } }
|
||||||
</style>
|
</style>
|
||||||
|
<?= site_icon_links() ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user