Upload via GUI (23 Dateien)
This commit is contained in:
@@ -1,44 +1,48 @@
|
||||
# DeskHub für Chrome (Erweiterung)
|
||||
|
||||
DeskHub in der Browserleiste: **Zähler und Benachrichtigungen** für neue Tickets, **Suche**, **Ticket anlegen** und Tickets mit einem Klick im Browser öffnen. Sie läuft in Chrome, Edge und Brave (Manifest V3) auf Windows, Mac und Linux und ist die einfachste Lösung für Mitarbeiter ohne Windows-Programm.
|
||||
DeskHub in der Browserleiste: **Tickets lesen und beantworten**, Aufgaben, eigener Status, Zähler und Benachrichtigungen. Sie läuft in Chrome, Edge und Brave (Manifest V3) auf Windows, Mac und Linux. Version 2 ist komplett neu gebaut (Oberfläche, Anmeldung, Funktionen).
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Zähler am Symbol:** Jede Minute fragt die Erweiterung den Server nach Neuem (neue nicht zugewiesene Tickets und Aktualisierungen bei deinen Tickets). Die Zahl am Symbol zählt, was seit dem letzten Öffnen dazugekommen ist; ein Klick auf das Symbol setzt sie zurück.
|
||||
- **Benachrichtigungen:** Bei Neuem meldet Chrome es (im Popup unten abschaltbar).
|
||||
- **Übersicht:** Meine offenen, überfällige und nicht zugewiesene Tickets sowie die als Nächstes fälligen Tickets.
|
||||
- **Suche:** In Betreff, Nummer, Name und Text, über alle Status.
|
||||
- **Neu:** Ticket anlegen (Anfrager, Betreff, Beschreibung, Abteilung, Priorität, optional Kunden benachrichtigen). Das Recht „Ticket anlegen“ ist Voraussetzung, sonst fehlt der Reiter.
|
||||
- **Rechtsklick auf Text oder Seite → „Als DeskHub-Ticket anlegen“:** Öffnet das Formular mit dem markierten Text und der Adresse der Seite.
|
||||
- **Ticket im Browser öffnen:** Ein Klick auf ein Ticket öffnet es in der Weboberfläche. Dafür erzeugt der Server eine einmalige Anmelde-Adresse (wie in den Apps), du musst dich dort nicht erneut anmelden.
|
||||
- **Start:** Begrüßung, **eigener Status** (Verfügbar, Beschäftigt, Pause, Offline), Kennzahlen (meine offenen, überfällige, nicht zugewiesene, unbeantwortete, heute gelöste, SLA-Quote) und die als Nächstes fälligen Tickets. Ein Klick auf eine Kennzahl öffnet die passende Ticketliste.
|
||||
- **Tickets:** Filter *Meine*, *Nicht zugewiesen*, *Überfällig*, *Unbeantwortet*, *Beobachtet*, *Wiedervorlage*, *Alle offenen*, *Gelöst/Geschlossen*; Suche in Betreff, Nummer, Name und Text; „Mehr laden“.
|
||||
- **Ticket-Ansicht direkt in der Erweiterung:** Verlauf als Chat (Kunde, Mitarbeiter, interne Notizen, Kommentare, Anhänge), **Antworten** (mit Textbausteinen, Signatur-Auswahl und Status nach dem Senden), **interne Notiz**, **Status ändern** (mit Abschlussgrund), **zuweisen** (auch „Mir zuweisen“), **Wiedervorlage**, **Beobachten**, Ticket im Browser öffnen. Strg+Enter sendet. Ein angefangener Text bleibt als Entwurf erhalten, auch wenn sich das Popup schließt.
|
||||
- **Aufgaben:** meine, alle offenen, erledigte; abhaken, neue Aufgabe mit Fälligkeit, Bearbeiter und Ticket, löschen.
|
||||
- **Neu:** Ticket anlegen (Anfrager, Betreff, Beschreibung, Abteilung, Priorität, Bearbeiter, Kunden benachrichtigen).
|
||||
- **Mehr:** Konto, Meldungen einzeln ein-/ausschalten, Darstellung (automatisch, hell, dunkel), Team-Übersicht mit Status, Weboberfläche öffnen, Abmelden.
|
||||
- **Seitenleiste:** Das Symbol oben rechts im Popup öffnet DeskHub in der Seitenleiste von Chrome (bleibt offen, volle Höhe). Alternativ als eigenes Fenster.
|
||||
- **Zähler und Benachrichtigungen:** Jede Minute fragt die Erweiterung den Server nach Neuem; die Zahl am Symbol zählt, was seit dem letzten Öffnen dazugekommen ist.
|
||||
- **Rechtsklick auf Text oder Seite → „Als DeskHub-Ticket anlegen“:** öffnet das Formular mit markiertem Text und Adresse der Seite.
|
||||
- **Tastenkürzel:** Alt+Shift+N neues Ticket, Alt+Shift+D Seitenleiste (unter `chrome://extensions/shortcuts` änderbar).
|
||||
- **Adressleiste:** „dh“, Leertaste, dann Ticketnummer (öffnet das Ticket) oder Suchbegriff (sucht Tickets).
|
||||
|
||||
## Installieren
|
||||
|
||||
1. In Chrome `chrome://extensions` öffnen und **Entwicklermodus** einschalten.
|
||||
2. **Entpackte Erweiterung laden** und den Ordner `chrome-erweiterung` wählen (oder das ZIP aus `release/` vorher entpacken).
|
||||
2. **Entpackte Erweiterung laden** und den Ordner `chrome-erweiterung` wählen (oder das ZIP aus `release/` vorher entpacken). Eine ältere Version vorher entfernen.
|
||||
3. Das DeskHub-Symbol in der Leiste anheften, anklicken und anmelden: Server-Adresse (zum Beispiel `support.example.de`), E-Mail und Passwort. Ist dein Konto mit einem Authenticator geschützt, fragt die Erweiterung danach den 6-stelligen Code.
|
||||
|
||||
Chrome fragt dabei **einmal** nach der Erlaubnis, auf deinen DeskHub-Server zuzugreifen. Die Erlaubnis gilt nur für diese eine Adresse, nicht für andere Webseiten, und wird beim Abmelden wieder zurückgegeben.
|
||||
Es gibt **keine Rückfrage** und keinen zweiten Anmeldeschritt: Der Zugriff auf den Server ist im Manifest erlaubt (`host_permissions`). Die Erweiterung spricht trotzdem nur mit dem Server, bei dem du dich anmeldest.
|
||||
|
||||
## Sicherheit und Datenschutz
|
||||
|
||||
- Verbindung nur über **HTTPS** (`http` nur für `localhost` zum Testen). Die Erweiterung folgt keinen Weiterleitungen und sendet keine Cookies.
|
||||
- Das Anmelde-Token liegt nur in `chrome.storage.local` dieser Erweiterung (nicht in `sync`, nicht im Seitencode). **Abmelden** sperrt das Token auf dem Server.
|
||||
- Rechte: `storage`, `alarms`, `notifications`, `contextMenus`. Zugriff auf Webseiten gibt es erst nach deiner Zustimmung bei der Anmeldung, und nur für deinen Server.
|
||||
- Die Oberfläche baut alles mit DOM-Aufrufen auf (kein `innerHTML` mit Serverdaten); die Inhaltsrichtlinie der Erweiterung erlaubt nur eigene Skripte und Verbindungen über HTTPS.
|
||||
- Texte, die du anlegst, werden als Text gesendet (Sonderzeichen maskiert); der Server bereinigt sie zusätzlich.
|
||||
- Das Anmelde-Token liegt nur in `chrome.storage.local` dieser Erweiterung (nicht in `sync`, nicht im Seitencode). **Abmelden** sperrt das Token auf dem Server und löscht lokale Daten samt Entwürfen.
|
||||
- Der Manifest-Zugriff gilt für alle HTTPS-Adressen, damit keine Berechtigungsabfrage nötig ist; der Code verbindet sich ausschließlich mit der angemeldeten Server-Adresse. Chrome zeigt bei einer Installation aus dem Web Store dafür eine einmalige Warnung (bei „Entpackte Erweiterung laden“ nicht).
|
||||
- Die Oberfläche baut alles mit DOM-Aufrufen auf (kein `innerHTML` mit Serverdaten); die Inhaltsrichtlinie erlaubt nur eigene Skripte und Verbindungen über HTTPS.
|
||||
- Texte, die du sendest, werden als Text übertragen (Sonderzeichen maskiert); der Server bereinigt sie zusätzlich.
|
||||
|
||||
## Grenzen
|
||||
## Hinweise
|
||||
|
||||
- Die Erweiterung fragt **jede Minute** ab (Chrome erlaubt für Erweiterungen nicht öfter). Ist Chrome geschlossen, gibt es keine Meldungen. Für Meldungen auch bei geschlossenem Browser nutzt die Android-App oder das Windows-Programm.
|
||||
- Antworten auf Tickets, Zuweisen, Status ändern und Notizen gehen nicht in der Erweiterung: Dafür öffnet ein Klick das Ticket in der Weboberfläche.
|
||||
- **Ein Ticket zu öffnen sperrt es für andere** (wie in der Weboberfläche) und weist ein nicht zugewiesenes Ticket dir zu. Beim Verlassen der Ansicht wird die Sperre wieder freigegeben.
|
||||
- Die Erweiterung fragt **jede Minute** ab (Chrome erlaubt für Erweiterungen nicht öfter). Ist Chrome geschlossen, gibt es keine Meldungen. Dafür gibt es die Android-App und das Windows-Programm.
|
||||
- Anhänge werden angezeigt, aber nicht heruntergeladen: Dafür das Ticket im Browser öffnen.
|
||||
- Die Oberfläche ist deutsch.
|
||||
- Sie ist (noch) nicht im Chrome Web Store. Das ZIP lässt sich dort als nicht gelistete Erweiterung hochladen, dafür braucht es ein Entwicklerkonto bei Google.
|
||||
|
||||
## Entwickeln und testen
|
||||
|
||||
```bash
|
||||
node --test test/core.test.mjs # Kern und Hintergrunddienst mit nachgebautem Browser und Server
|
||||
node --test test/core.test.mjs # Kern, Hintergrunddienst, Zeitangaben mit nachgebautem Browser und Server
|
||||
```
|
||||
|
||||
Gegen einen **echten** DeskHub-Server (Testinstallation, nicht Produktion):
|
||||
@@ -47,7 +51,7 @@ Gegen einen **echten** DeskHub-Server (Testinstallation, nicht Produktion):
|
||||
DESKHUB_URL=http://127.0.0.1:8181 DESKHUB_EMAIL=… DESKHUB_PASSWORD=… node --test test/integration.test.mjs
|
||||
```
|
||||
|
||||
Der Integrationstest meldet an, ruft Stammdaten, Übersicht und Suche ab, legt ein Ticket an, prüft die Aktivitätszählung, öffnet ein Ticket per einmaliger Adresse und meldet ab (danach muss das alte Token abgelehnt werden). Ohne `DESKHUB_URL` wird er übersprungen.
|
||||
Der Integrationstest meldet an, ruft Stammdaten, Übersicht und Suche ab, legt ein Ticket an, prüft die Aktivitätszählung, antwortet, schreibt Notizen, setzt Wiedervorlage, Beobachten und Status, legt Aufgaben an und meldet ab (danach muss das alte Token abgelehnt werden). Ohne `DESKHUB_URL` wird er übersprungen. Der Suchtest erwartet das Ticket `T-TEST-0001` der Testinstallation.
|
||||
|
||||
Packen für die Weitergabe: `node pack.mjs` erzeugt `../release/deskhub-chrome-erweiterung-<Version>.zip` mit nur den Laufzeitdateien.
|
||||
|
||||
@@ -55,8 +59,10 @@ Packen für die Weitergabe: `node pack.mjs` erzeugt `../release/deskhub-chrome-e
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|---|---|
|
||||
| `manifest.json` | Rechte, Hintergrunddienst, Inhaltsrichtlinie |
|
||||
| `background.js` | Abfrage jede Minute, Zähler, Benachrichtigungen, Rechtsklick-Menü |
|
||||
| `popup.html/js/css` | Oberfläche (Anmeldung, Übersicht, Suche, Neu) |
|
||||
| `lib/core.js` | Serveradresse, Anmeldung, API-Aufrufe, Sicherheitsprüfungen |
|
||||
| `manifest.json` | Rechte, Hintergrunddienst, Seitenleiste, Tastenkürzel, Adressleiste, Inhaltsrichtlinie |
|
||||
| `background.js` | Abfrage jede Minute, Zähler, Benachrichtigungen, Rechtsklick-Menü, Kürzel, Adressleiste |
|
||||
| `app.html/css/js` | Gerüst der Oberfläche (Popup, Seitenleiste, Fenster) |
|
||||
| `views/` | Ansichten: Anmeldung, Start, Tickets, Ticket, Aufgaben, Neu, Mehr |
|
||||
| `lib/core.js` | Serveradresse, Anmeldung, API-Aufrufe, Zeitangaben |
|
||||
| `lib/dom.js`, `lib/ui.js` | DOM-Baukasten, Symbole, gemeinsame Bausteine |
|
||||
| `test/` | Tests |
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
--bg: #eef2f6; --card: #ffffff; --soft: #f5f8fb; --ink: #14202e; --muted: #61708a; --line: #e1e7ef;
|
||||
--brand: #0f766e; --brand-2: #14a39a; --brand-ink: #ffffff; --brand-soft: #dcf3f0;
|
||||
--bad: #c62828; --bad-bg: #fde8e8; --ok: #15803d; --ok-bg: #dcfce7; --warn: #b45309; --warn-bg: #fef3c7; --info: #1d4ed8; --info-bg: #e0e9ff;
|
||||
--shadow: 0 1px 2px rgba(15, 30, 50, .06), 0 4px 14px rgba(15, 30, 50, .06);
|
||||
--radius: 14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0a111a; --card: #121c2a; --soft: #182535; --ink: #e8eef6; --muted: #92a2b9; --line: #223247;
|
||||
--brand: #2dd4bf; --brand-2: #5eead4; --brand-ink: #03201c; --brand-soft: #0f2c2d;
|
||||
--bad: #f87171; --bad-bg: #3b1719; --ok: #4ade80; --ok-bg: #12301f; --warn: #fbbf24; --warn-bg: #3a2a0c; --info: #93b4ff; --info-bg: #17284d;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 4px 14px rgba(0, 0, 0, .3);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0a111a; --card: #121c2a; --soft: #182535; --ink: #e8eef6; --muted: #92a2b9; --line: #223247;
|
||||
--brand: #2dd4bf; --brand-2: #5eead4; --brand-ink: #03201c; --brand-soft: #0f2c2d;
|
||||
--bad: #f87171; --bad-bg: #3b1719; --ok: #4ade80; --ok-bg: #12301f; --warn: #fbbf24; --warn-bg: #3a2a0c; --info: #93b4ff; --info-bg: #17284d;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 4px 14px rgba(0, 0, 0, .3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; width: 400px; height: 600px; overflow: hidden; }
|
||||
body.wide { width: auto; height: 100vh; }
|
||||
#app { display: flex; flex-direction: column; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.muted { color: var(--muted); font-size: 12.5px; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
|
||||
/* Kopf */
|
||||
header.top { display: flex; align-items: center; gap: 8px; padding: 10px 12px; color: #fff; background: linear-gradient(135deg, #0b3f43, var(--brand)); flex: none; }
|
||||
header.top .logo { width: 26px; height: 26px; border-radius: 8px; flex: none; }
|
||||
header.top .title { font-weight: 700; font-size: 15px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
header.top .iconbtn { color: #e6fffb; background: rgba(255, 255, 255, .12); }
|
||||
header.top .iconbtn:hover { background: rgba(255, 255, 255, .24); }
|
||||
.iconbtn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 0; border-radius: 10px; background: transparent; color: var(--muted); }
|
||||
.iconbtn:hover { background: var(--soft); color: var(--ink); }
|
||||
.statusdot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: #94a3b8; box-shadow: 0 0 0 2px rgba(255, 255, 255, .5); }
|
||||
.statusdot.available { background: #22c55e; } .statusdot.busy { background: #ef4444; } .statusdot.break { background: #f59e0b; } .statusdot.offline { background: #94a3b8; }
|
||||
|
||||
/* Inhalt */
|
||||
.content { flex: 1; overflow-y: auto; padding: 12px; scroll-behavior: smooth; }
|
||||
.stack { display: grid; gap: 10px; align-content: start; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 12px; }
|
||||
.card h2, h2.sec { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 8px; font-weight: 700; }
|
||||
.hello { font-size: 19px; font-weight: 800; margin: 2px 0 0; }
|
||||
|
||||
/* Navigation unten */
|
||||
nav.nav { display: flex; background: var(--card); border-top: 1px solid var(--line); flex: none; padding: 4px 6px 6px; }
|
||||
nav.nav button { flex: 1; display: grid; justify-items: center; gap: 1px; padding: 6px 2px; background: none; border: 0; border-radius: 10px; color: var(--muted); font-size: 11px; font-weight: 600; position: relative; }
|
||||
nav.nav button.on { color: var(--brand); background: var(--brand-soft); }
|
||||
nav.nav .count { position: absolute; top: 1px; right: 22%; background: var(--bad); color: #fff; border-radius: 9px; padding: 0 5px; font-size: 10px; line-height: 15px; }
|
||||
|
||||
/* Kennzahlen */
|
||||
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat { text-align: left; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; box-shadow: var(--shadow); }
|
||||
.stat:hover { border-color: var(--brand); }
|
||||
.stat .v { font-size: 26px; font-weight: 800; line-height: 1.1; }
|
||||
.stat .k { color: var(--muted); font-size: 12px; }
|
||||
.stat.warn .v { color: var(--bad); }
|
||||
.stat.good .v { color: var(--ok); }
|
||||
|
||||
/* Segmente und Chips */
|
||||
.seg { display: flex; gap: 4px; background: var(--soft); padding: 3px; border-radius: 12px; }
|
||||
.seg button { flex: 1; border: 0; background: transparent; padding: 6px 4px; border-radius: 9px; font-size: 12px; font-weight: 600; color: var(--muted); display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
.seg button.on { background: var(--card); color: var(--ink); box-shadow: var(--shadow); }
|
||||
.chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip { flex: none; border: 1px solid var(--line); background: var(--card); border-radius: 999px; padding: 5px 11px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
||||
.chip.on { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); }
|
||||
|
||||
/* Ticketliste */
|
||||
ul.tickets { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
.tk { width: 100%; text-align: left; display: grid; grid-template-columns: 4px 1fr; gap: 0 10px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 9px 12px 9px 0; box-shadow: var(--shadow); overflow: hidden; }
|
||||
.tk:hover { border-color: var(--brand); }
|
||||
.tk .bar { grid-row: 1 / span 3; border-radius: 4px; margin: -9px 0; background: var(--muted); }
|
||||
.tk .subj { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tk .meta { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; min-width: 0; }
|
||||
.tk .row1, .tk .row2 { grid-column: 2; min-width: 0; }
|
||||
.tk .row2 { margin-top: 3px; }
|
||||
.tk .over { color: var(--bad); font-weight: 700; }
|
||||
.tk.is-over { border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); }
|
||||
.pill { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 1px 8px; font-size: 11.5px; font-weight: 700; background: var(--soft); color: var(--muted); white-space: nowrap; }
|
||||
.pill.open { background: var(--info-bg); color: var(--info); } .pill.pending { background: var(--warn-bg); color: var(--warn); } .pill.answered { background: var(--ok-bg); color: var(--ok); }
|
||||
.pill.on_hold { background: var(--soft); color: var(--muted); } .pill.resolved { background: var(--ok-bg); color: var(--ok); } .pill.closed { background: var(--soft); color: var(--muted); }
|
||||
.pill.prio { color: #fff; }
|
||||
.avatar { width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--brand); color: var(--brand-ink); font-size: 11px; font-weight: 800; flex: none; }
|
||||
.avatar.cust { background: #64748b; color: #fff; }
|
||||
|
||||
/* Suchfeld */
|
||||
.searchbox { position: relative; }
|
||||
.searchbox svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--muted); }
|
||||
.searchbox input[type=search] { padding-left: 34px; }
|
||||
|
||||
/* Formulare */
|
||||
form { display: grid; gap: 10px; }
|
||||
label { display: grid; gap: 4px; font-size: 12.5px; font-weight: 650; }
|
||||
label.inline { display: flex; align-items: center; gap: 8px; font-weight: 500; }
|
||||
input[type=text], input[type=email], input[type=password], input[type=search], input[type=datetime-local], input[type=number], select, textarea { width: 100%; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
input:focus, select:focus, textarea:focus { outline: 3px solid var(--brand-soft); border-color: var(--brand); }
|
||||
.btn { border: 1px solid var(--line); background: var(--card); border-radius: 10px; padding: 8px 13px; font-weight: 650; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.btn:hover { border-color: var(--brand); }
|
||||
.btn.primary { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); }
|
||||
.btn.primary:hover { filter: brightness(1.07); }
|
||||
.btn.danger { color: var(--bad); }
|
||||
.btn.small { padding: 4px 9px; font-size: 12.5px; border-radius: 8px; }
|
||||
.btn:disabled { opacity: .55; cursor: default; }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Meldungen */
|
||||
.msg { padding: 9px 11px; border-radius: 10px; font-size: 13px; }
|
||||
.msg.err { background: var(--bad-bg); color: var(--bad); }
|
||||
.msg.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.msg.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.empty { text-align: center; color: var(--muted); padding: 26px 8px; }
|
||||
.spinner { width: 22px; height: 22px; border: 3px solid var(--line); border-top-color: var(--brand); border-radius: 50%; animation: spin .8s linear infinite; margin: 26px auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#toasts { position: fixed; left: 12px; right: 12px; bottom: 74px; z-index: 50; pointer-events: none; }
|
||||
.toast { background: #14202e; color: #fff; padding: 9px 13px; border-radius: 11px; box-shadow: var(--shadow); font-size: 13px; }
|
||||
.toast.err { background: var(--bad); }
|
||||
|
||||
/* Ticket-Ansicht */
|
||||
.tdetail h1 { font-size: 16px; margin: 0 0 6px; line-height: 1.3; }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.thread { display: grid; gap: 4px; }
|
||||
.bubble { border-radius: 14px; padding: 9px 12px; background: var(--card); border: 1px solid var(--line); max-width: 92%; box-shadow: var(--shadow); }
|
||||
.bubble .who { font-size: 12px; font-weight: 700; display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
|
||||
.bubble .when { font-weight: 400; color: var(--muted); }
|
||||
.bubble .text { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 3px; }
|
||||
.bubble.staff { background: var(--brand-soft); border-color: transparent; margin-left: auto; }
|
||||
.bubble.note { background: var(--warn-bg); border-color: transparent; max-width: 100%; }
|
||||
.bubble.system { background: transparent; box-shadow: none; border-style: dashed; color: var(--muted); max-width: 100%; font-size: 12.5px; }
|
||||
.tag { display: inline-block; font-size: 10.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--warn); }
|
||||
.files { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.file { display: inline-flex; gap: 4px; align-items: center; font-size: 12px; background: var(--soft); border-radius: 8px; padding: 2px 8px; color: var(--muted); }
|
||||
.comment { margin: 6px 0 0 10px; padding-left: 8px; border-left: 3px solid var(--info); font-size: 12.5px; }
|
||||
.composer { padding: 10px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); display: grid; gap: 8px; }
|
||||
.composer textarea { min-height: 84px; }
|
||||
.sheet { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px; display: grid; gap: 8px; box-shadow: var(--shadow); }
|
||||
|
||||
/* Mehr */
|
||||
.listrow { display: flex; align-items: center; gap: 10px; padding: 9px 2px; border-top: 1px solid var(--line); }
|
||||
.listrow:first-of-type { border-top: 0; }
|
||||
.switch { position: relative; width: 38px; height: 22px; flex: none; }
|
||||
.switch input { appearance: none; width: 38px; height: 22px; border-radius: 11px; background: var(--line); margin: 0; cursor: pointer; transition: background .15s; }
|
||||
.switch input:checked { background: var(--brand); }
|
||||
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; pointer-events: none; }
|
||||
.switch:has(input:checked)::after { transform: translateX(16px); }
|
||||
|
||||
/* Anmeldung */
|
||||
.login { display: grid; gap: 14px; padding: 26px 20px; align-content: center; min-height: 100%; }
|
||||
.login .brand { display: grid; justify-items: center; gap: 6px; text-align: center; }
|
||||
.login .brand img { width: 56px; height: 56px; border-radius: 16px; box-shadow: var(--shadow); }
|
||||
.login .brand h1 { margin: 0; font-size: 22px; }
|
||||
.task { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.task input[type=checkbox] { width: 18px; height: 18px; margin-top: 2px; accent-color: var(--brand); }
|
||||
.task.done .tt { text-decoration: line-through; color: var(--muted); }
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DeskHub</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
// Oberfläche der Erweiterung: Gerüst (Kopf, Inhalt, untere Navigation) und Weiterleitung zu den Ansichten.
|
||||
// Läuft im Popup, in der Seitenleiste (?mode=panel) und im eigenen Fenster (?mode=window).
|
||||
import { h, clear, icon, toast, errText } from './lib/dom.js';
|
||||
import { getSession, getSettings, logout } from './lib/core.js';
|
||||
import { renderLogin } from './views/login.js';
|
||||
import { renderHome } from './views/home.js';
|
||||
import { renderTickets } from './views/tickets.js';
|
||||
import { renderTicket } from './views/ticket.js';
|
||||
import { renderTasks } from './views/tasks.js';
|
||||
import { renderCreate } from './views/create.js';
|
||||
import { renderMore } from './views/more.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const params = new URLSearchParams(location.search);
|
||||
const mode = params.get('mode') || (params.get('window') ? 'window' : 'popup');
|
||||
if (mode !== 'popup') document.body.classList.add('wide');
|
||||
|
||||
const TABS = [['home', 'Start', 'home'], ['tickets', 'Tickets', 'list'], ['tasks', 'Aufgaben', 'check'], ['create', 'Neu', 'plus'], ['more', 'Mehr', 'menu']];
|
||||
const VIEWS = { home: renderHome, tickets: renderTickets, tasks: renderTasks, create: renderCreate, more: renderMore, ticket: renderTicket };
|
||||
const TITLES = { home: 'DeskHub', tickets: 'Tickets', tasks: 'Aufgaben', create: 'Neues Ticket', more: 'Mehr', ticket: 'Ticket' };
|
||||
|
||||
let session = null;
|
||||
let route = { view: 'home', params: {} };
|
||||
const trail = [];
|
||||
let leaveHandlers = [];
|
||||
|
||||
export function applyTheme(theme) {
|
||||
if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme; else delete document.documentElement.dataset.theme;
|
||||
}
|
||||
|
||||
async function runLeave() { const hs = leaveHandlers; leaveHandlers = []; for (const fn of hs) { try { await fn(); } catch { /* egal */ } } }
|
||||
|
||||
const ctx = {
|
||||
get session() { return session; },
|
||||
isPanel: mode === 'panel', isWindow: mode === 'window',
|
||||
applyTheme,
|
||||
go(view, p = {}) { if (view === 'ticket') trail.push(route); else trail.length = 0; navigate(view, p); },
|
||||
back() { const prev = trail.pop() || { view: 'tickets', params: {} }; navigate(prev.view, prev.params); },
|
||||
reload() { navigate(route.view, route.params); },
|
||||
onLeave(fn) { leaveHandlers.push(fn); },
|
||||
scrollToEnd() { const c = document.querySelector('.content'); if (c) c.scrollTop = c.scrollHeight; },
|
||||
setStatusDot(status) { const d = document.getElementById('status-dot'); if (d) d.className = `statusdot ${status}`; },
|
||||
loggedIn(s) { session = s; trail.length = 0; navigate('home'); },
|
||||
logoutExpired() { session = null; renderLogin(app, ctx, { serverUrl: undefined, error: 'Die Anmeldung ist abgelaufen. Bitte neu anmelden.' }); },
|
||||
async logout() { await runLeave(); await logout(); await chrome.runtime.sendMessage({ type: 'session-changed' }).catch(() => {}); session = null; renderLogin(app, ctx); },
|
||||
async openPanel() {
|
||||
try { const win = await chrome.windows.getCurrent(); await chrome.sidePanel.open({ windowId: win.id }); if (mode === 'popup') window.close(); }
|
||||
catch (e) { toast(errText(e), 'err'); }
|
||||
},
|
||||
async openWindow() {
|
||||
await chrome.windows.create({ url: chrome.runtime.getURL('app.html?mode=window'), type: 'popup', width: 460, height: 760 });
|
||||
if (mode === 'popup') window.close();
|
||||
},
|
||||
};
|
||||
|
||||
async function navigate(view, p = {}) {
|
||||
await runLeave();
|
||||
route = { view, params: p };
|
||||
const isDetail = view === 'ticket';
|
||||
const activeTab = isDetail ? (trail.at(-1)?.view === 'tickets' ? 'tickets' : trail.at(-1)?.view || 'tickets') : view;
|
||||
const content = h('div', { class: 'content' });
|
||||
const body = h('div', { class: 'stack-host' });
|
||||
content.append(body);
|
||||
|
||||
const header = h('header', { class: 'top' },
|
||||
isDetail ? h('button', { class: 'iconbtn', title: 'Zurück', onclick: () => ctx.back() }, icon('back', 18)) : h('img', { class: 'logo', src: 'icons/icon48.png', alt: '' }),
|
||||
h('div', { class: 'title' }, isDetail ? `#${p.number}` : TITLES[view] || 'DeskHub'),
|
||||
h('span', { id: 'status-dot', class: 'statusdot', title: 'Dein Status' }),
|
||||
h('button', { class: 'iconbtn', title: 'Neu laden', onclick: () => ctx.reload() }, icon('refresh', 16)),
|
||||
mode === 'popup' ? h('button', { class: 'iconbtn', title: 'In der Seitenleiste öffnen', onclick: () => ctx.openPanel() }, icon('panel', 16)) : null);
|
||||
|
||||
const nav = h('nav', { class: 'nav', 'aria-label': 'Hauptmenü' }, TABS.map(([key, label, ic]) => h('button', { class: key === activeTab ? 'on' : '', onclick: () => ctx.go(key), 'aria-label': label }, icon(ic, 20), label)));
|
||||
app.replaceChildren(header, content, nav);
|
||||
chrome.runtime.sendMessage({ type: 'reset-badge' }).catch(() => {});
|
||||
try { await VIEWS[view]?.(body, ctx, p); } catch (e) { body.replaceChildren(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', () => { runLeave(); });
|
||||
|
||||
(async () => {
|
||||
applyTheme((await getSettings()).theme);
|
||||
session = await getSession();
|
||||
if (!session) return renderLogin(app, ctx);
|
||||
chrome.runtime.sendMessage({ type: 'presence' }).catch(() => {});
|
||||
const open = params.get('ticket');
|
||||
if (open) { trail.push({ view: 'tickets', params: {} }); return navigate('ticket', { number: open }); }
|
||||
if (params.get('create')) return navigate('create');
|
||||
if (params.get('q')) return navigate('tickets', { q: params.get('q') });
|
||||
navigate(params.get('view') || 'home');
|
||||
})();
|
||||
@@ -1,21 +1,27 @@
|
||||
// Hintergrunddienst: prüft jede Minute auf neue Tickets/Antworten, zeigt den Zähler am Symbol und Benachrichtigungen,
|
||||
// bietet „Als DeskHub-Ticket anlegen“ im Rechtsklick-Menü an.
|
||||
// Hintergrunddienst: prüft jede Minute auf Neues, zeigt Zähler und Benachrichtigungen, Rechtsklick-Menü,
|
||||
// Tastenkürzel und die Adressleisten-Suche („dh“ + Leertaste).
|
||||
import { apiCall, getSession, getSettings, truncate } from './lib/core.js';
|
||||
|
||||
const ALARM = 'deskhub-poll';
|
||||
const MENU = 'deskhub-create';
|
||||
const APP = 'app.html';
|
||||
|
||||
async function setBadge(count, color = '#0f766e') {
|
||||
await chrome.action.setBadgeBackgroundColor({ color });
|
||||
await chrome.action.setBadgeText({ text: count > 0 ? (count > 99 ? '99+' : String(count)) : '' });
|
||||
}
|
||||
|
||||
async function ensureSetup() {
|
||||
async function ensureAlarm() {
|
||||
if (!(await chrome.alarms.get(ALARM))) chrome.alarms.create(ALARM, { periodInMinutes: 1, delayInMinutes: 0.1 });
|
||||
}
|
||||
async function ensureSetup() {
|
||||
await ensureAlarm();
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
chrome.contextMenus.create({ id: MENU, title: 'Als DeskHub-Ticket anlegen', contexts: ['selection', 'page'] });
|
||||
});
|
||||
}
|
||||
// Auch wenn der Hintergrunddienst nur neu gestartet wurde (kein Installations- oder Start-Ereignis): Die Abfrage-Uhr muss laufen
|
||||
ensureAlarm();
|
||||
|
||||
chrome.runtime.onInstalled.addListener(ensureSetup);
|
||||
chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); });
|
||||
@@ -27,14 +33,17 @@ export async function poll() {
|
||||
const { lastSeen, badgeCount = 0 } = await chrome.storage.local.get(['lastSeen', 'badgeCount']);
|
||||
try {
|
||||
const { data } = await apiCall('activity.php', { query: lastSeen ? { since: lastSeen } : {} });
|
||||
const fresh = (data.newReplies || 0) + (data.newUnassigned || 0);
|
||||
const settings = await getSettings();
|
||||
const unassigned = settings.notifyUnassigned ? data.newUnassigned || 0 : 0;
|
||||
const replies = settings.notifyMine ? data.newReplies || 0 : 0;
|
||||
const fresh = unassigned + replies;
|
||||
const total = badgeCount + fresh;
|
||||
await chrome.storage.local.set({ lastSeen: data.serverTime, badgeCount: total });
|
||||
await setBadge(total);
|
||||
if (fresh > 0 && (await getSettings()).notifications) {
|
||||
if (fresh > 0 && settings.notifications) {
|
||||
const parts = [];
|
||||
if (data.newUnassigned) parts.push(`${data.newUnassigned} neu und nicht zugewiesen`);
|
||||
if (data.newReplies) parts.push(`${data.newReplies} Aktualisierung${data.newReplies === 1 ? '' : 'en'} bei deinen Tickets`);
|
||||
if (unassigned) parts.push(`${unassigned} neu und nicht zugewiesen`);
|
||||
if (replies) parts.push(`${replies} Aktualisierung${replies === 1 ? '' : 'en'} bei deinen Tickets`);
|
||||
chrome.notifications.create('deskhub-new', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub', message: parts.join(', '), priority: 1 });
|
||||
}
|
||||
return { fresh, total };
|
||||
@@ -51,9 +60,7 @@ export async function poll() {
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); });
|
||||
|
||||
async function openCreateWindow() {
|
||||
return chrome.windows.create({ url: chrome.runtime.getURL('popup.html?window=1&create=1'), type: 'popup', width: 440, height: 680 });
|
||||
}
|
||||
const openWindow = (query = '') => chrome.windows.create({ url: chrome.runtime.getURL(`${APP}?mode=window${query}`), type: 'popup', width: 460, height: 760 });
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
if (info.menuItemId !== MENU) return;
|
||||
@@ -61,20 +68,41 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
const selection = (info.selectionText || '').trim();
|
||||
const source = tab?.url && /^https?:/i.test(tab.url) ? `\n\nQuelle: ${tab.url}` : '';
|
||||
await chrome.storage.local.set({ prefill: { subject, message: (selection + source).trim() } });
|
||||
await openCreateWindow();
|
||||
await openWindow('&create=1');
|
||||
});
|
||||
|
||||
chrome.commands?.onCommand.addListener(async (command, tab) => {
|
||||
if (command === 'quick-create') await openWindow('&create=1');
|
||||
else if (command === 'open-panel') {
|
||||
try { await chrome.sidePanel.open({ windowId: tab?.windowId ?? (await chrome.windows.getCurrent()).id }); } catch { await openWindow(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Adressleiste: „dh 261031-0299“ öffnet das Ticket, „dh Drucker“ sucht danach.
|
||||
const looksLikeTicket = (s) => /^#?(\d{4,}-\d+|\d{5,}|[A-Za-z]{2,6}-\d+)$/.test(s.trim());
|
||||
chrome.omnibox?.onInputChanged.addListener((text, suggest) => {
|
||||
const t = text.trim();
|
||||
if (!t) return suggest([]);
|
||||
suggest(looksLikeTicket(t) ? [{ content: t, description: `Ticket ${t.replace(/^#/, '')} öffnen` }] : [{ content: t, description: `Tickets suchen: ${t.replace(/[<>&]/g, '')}` }]);
|
||||
});
|
||||
chrome.omnibox?.onInputEntered.addListener(async (text) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
await openWindow(looksLikeTicket(t) ? `&ticket=${encodeURIComponent(t.replace(/^#/, ''))}` : `&q=${encodeURIComponent(t)}`);
|
||||
});
|
||||
|
||||
chrome.notifications.onClicked.addListener(async (id) => {
|
||||
chrome.notifications.clear(id);
|
||||
try { await chrome.action.openPopup(); } catch { await chrome.windows.create({ url: chrome.runtime.getURL('popup.html?window=1'), type: 'popup', width: 440, height: 680 }); }
|
||||
try { await chrome.action.openPopup(); } catch { await openWindow(); }
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (msg?.type === 'reset-badge') { await chrome.storage.local.set({ badgeCount: 0 }); await setBadge(0); sendResponse({ ok: true }); }
|
||||
else if (msg?.type === 'poll-now') sendResponse(await poll());
|
||||
else if (msg?.type === 'session-changed') { await chrome.storage.local.remove(['lastSeen', 'badgeCount']); await setBadge(0); sendResponse(await poll()); }
|
||||
else if (msg?.type === 'presence') { await apiCall('presence.php', { method: 'POST', body: {} }).catch(() => {}); sendResponse({ ok: true }); }
|
||||
else if (msg?.type === 'session-changed') { await ensureAlarm(); await chrome.storage.local.remove(['lastSeen', 'badgeCount']); await setBadge(0); sendResponse(await poll()); }
|
||||
else sendResponse({ ok: false });
|
||||
})();
|
||||
})().catch((e) => { try { sendResponse({ error: (e && e.code) || 'ERROR' }); } catch { /* Fenster schon zu */ } });
|
||||
return true; // Antwort kommt asynchron
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Gemeinsamer Kern von Popup und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen.
|
||||
// Gemeinsamer Kern von Oberfläche und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen.
|
||||
// Das Anmelde-Token liegt nur in chrome.storage.local dieser Erweiterung (nie in sync, nie im Seitencode).
|
||||
// Der Zugriff auf den Server ist im Manifest erlaubt (host_permissions): Es gibt keine Rückfrage beim Anmelden.
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
|
||||
@@ -25,11 +26,15 @@ export async function getSession() {
|
||||
return session && session.token && session.serverUrl ? session : null;
|
||||
}
|
||||
export const setSession = (session) => chrome.storage.local.set({ session });
|
||||
export async function clearSession() { await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta']); }
|
||||
export async function clearSession() {
|
||||
const drafts = Object.keys(await chrome.storage.local.get(null)).filter((k) => k.startsWith('draft:')); // nicht gesendete Entwürfe gehören zur Anmeldung
|
||||
await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta', ...drafts]);
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS = { notifications: true, notifyUnassigned: true, notifyMine: true, theme: 'auto', compact: false };
|
||||
export async function getSettings() {
|
||||
const { settings } = await chrome.storage.local.get('settings');
|
||||
return { notifications: true, ...(settings || {}) };
|
||||
return { ...DEFAULT_SETTINGS, ...(settings || {}) };
|
||||
}
|
||||
export const setSettings = (settings) => chrome.storage.local.set({ settings });
|
||||
|
||||
@@ -40,13 +45,13 @@ export async function apiCall(path, { method = 'GET', query, body, auth = true,
|
||||
if (!base) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.');
|
||||
const url = new URL(`${base}/api/staff/v1/${path}`);
|
||||
for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache' };
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (auth) {
|
||||
if (!session?.token) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.');
|
||||
headers.Authorization = `Bearer ${session.token}`;
|
||||
headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`;
|
||||
}
|
||||
const init = { method, headers, redirect: 'error', credentials: 'omit', signal: signal || AbortSignal.timeout(30000) };
|
||||
const init = { method, headers, redirect: 'error', credentials: 'omit', cache: 'no-store', signal: signal || AbortSignal.timeout(30000) };
|
||||
if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
||||
let res;
|
||||
try { res = await fetch(url, init); } catch { throw new ApiError(0, 'NETWORK', 'Server nicht erreichbar.'); }
|
||||
@@ -60,17 +65,24 @@ export async function apiCall(path, { method = 'GET', query, body, auth = true,
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
/** Fragt bei Bedarf die Berechtigung für genau diesen Server ab (nur diese eine Adresse, nicht „alle Webseiten“). Muss aus einem Klick heraus aufgerufen werden. */
|
||||
export async function ensureHostPermission(serverUrl) {
|
||||
const pattern = new URL(serverUrl).origin + '/*';
|
||||
if (await chrome.permissions.contains({ origins: [pattern] })) return true;
|
||||
return chrome.permissions.request({ origins: [pattern] });
|
||||
/**
|
||||
* Schickt eine POST-Anfrage los, ohne auf etwas zu warten (für „beim Verlassen aufräumen“). Läuft synchron mit keepalive und überlebt deshalb
|
||||
* das Schließen des Popups, das sich bei jedem Klick daneben schließt. Die Sitzung wird übergeben, weil ein Speicherzugriff dafür zu spät käme.
|
||||
*/
|
||||
export function apiBeacon(session, path, body) {
|
||||
if (!session?.token || !session?.serverUrl) return;
|
||||
try {
|
||||
fetch(`${session.serverUrl}/api/staff/v1/${path}`, {
|
||||
method: 'POST', keepalive: true, redirect: 'error', credentials: 'omit', cache: 'no-store',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${session.token}`, 'X-DeskHub-Authorization': `Bearer ${session.token}` },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
|
||||
/** Meldet an. @returns {{requiresTotp:true}|{session:object}} */
|
||||
export async function login(serverUrlInput, email, password, totpCode) {
|
||||
const serverUrl = normalizeServerUrl(serverUrlInput);
|
||||
if (!(await ensureHostPermission(serverUrl))) throw new Error('Ohne Erlaubnis für diesen Server kann sich die Erweiterung nicht anmelden.');
|
||||
const { status, data } = await apiCall('login.php', {
|
||||
method: 'POST', auth: false, serverUrl,
|
||||
body: { email, password, totpCode: totpCode || null, deviceName: 'Chrome-Erweiterung' },
|
||||
@@ -81,16 +93,27 @@ export async function login(serverUrlInput, email, password, totpCode) {
|
||||
return { session };
|
||||
}
|
||||
|
||||
/** Meldet ab (Token auf dem Server sperren) und entfernt Daten und Berechtigung dieses Servers. */
|
||||
/** Meldet ab: Token auf dem Server sperren und lokale Daten entfernen. */
|
||||
export async function logout() {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ }
|
||||
try { await chrome.permissions.remove({ origins: [new URL(session.serverUrl).origin + '/*'] }); } catch { /* ignorieren */ }
|
||||
}
|
||||
if (session) { try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ } }
|
||||
await clearSession();
|
||||
}
|
||||
|
||||
/** Stammdaten (Abteilungen, Prioritäten, Mitarbeiter, Status, Tags) – 10 Minuten zwischengespeichert. */
|
||||
export async function getMeta(force = false) {
|
||||
const { meta } = await chrome.storage.local.get('meta');
|
||||
if (!force && meta && Date.now() - meta.at < 10 * 60 * 1000) return meta;
|
||||
const { data } = await apiCall('metadata.php');
|
||||
const fresh = {
|
||||
at: Date.now(),
|
||||
departments: (data.departments || []).filter((d) => !/\(deaktiviert\)$/.test(d.name)),
|
||||
priorities: data.priorities || [], agents: data.agents || [], statuses: data.statuses || [], tags: data.tags || [],
|
||||
};
|
||||
await chrome.storage.local.set({ meta: fresh });
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Pfad eines Tickets in der Weboberfläche. Nur Ticketnummern im bekannten Format. */
|
||||
export function ticketPath(number) {
|
||||
const n = String(number || '');
|
||||
@@ -117,3 +140,41 @@ export function plainToHtml(text) {
|
||||
}
|
||||
|
||||
export const truncate = (s, n) => (String(s).length > n ? String(s).slice(0, n - 1) + '…' : String(s));
|
||||
|
||||
/** „2026-10-31 04:03:00“ (Serverzeit) → Date. Der Server liefert Zeiten ohne Zeitzone; sie werden wie lokale Zeit behandelt. */
|
||||
export function parseServerTime(s) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(String(s || ''));
|
||||
return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)) : null;
|
||||
}
|
||||
|
||||
/** Kurzform wie „vor 5 Min“, „gestern 14:02“, „31.10. 04:03“. */
|
||||
export function timeAgo(s, now = new Date()) {
|
||||
const d = parseServerTime(s);
|
||||
if (!d) return '';
|
||||
const diff = Math.round((now - d) / 1000);
|
||||
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
if (diff < 0) { // Zukunft (z. B. Wiedervorlage)
|
||||
const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1);
|
||||
if (d.toDateString() === now.toDateString()) return `heute ${hm}`;
|
||||
if (d.toDateString() === tomorrow.toDateString()) return `morgen ${hm}`;
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
|
||||
}
|
||||
if (diff < 60) return 'gerade eben';
|
||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return `heute ${hm}`;
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return `gestern ${hm}`;
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
|
||||
}
|
||||
|
||||
export const fmtBytes = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B');
|
||||
export const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2).map((p) => p[0]?.toUpperCase() || '').join('') || '?';
|
||||
export const safeColor = (c) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : null);
|
||||
|
||||
export const RESOLUTION_REASONS = {
|
||||
fixed: 'Fehler behoben', guidance: 'Anleitung oder Beratung', duplicate: 'Doppelt / zusammengeführt', not_reproducible: 'Nicht reproduzierbar',
|
||||
no_action: 'Keine Maßnahme erforderlich', spam: 'Spam oder Sperre', other: 'Sonstiges',
|
||||
};
|
||||
export const STATUS_LABELS = { open: 'Offen', pending: 'Wartet auf Kunde', answered: 'Beantwortet', on_hold: 'Pausiert', resolved: 'Gelöst', closed: 'Geschlossen' };
|
||||
export const AVAILABILITY = { available: 'Verfügbar', busy: 'Beschäftigt', break: 'Pause', offline: 'Offline' };
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Kleiner DOM-Baukasten. Serverdaten gelangen nie über innerHTML in die Seite (nur als Text oder Attribut).
|
||||
|
||||
/** h('div', {class:'x', onclick: fn}, 'Text', kind, …) */
|
||||
export function h(tag, attrs = {}, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'value') el.value = v;
|
||||
else if (k === 'checked') el.checked = !!v;
|
||||
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
|
||||
else el.setAttribute(k, v === true ? '' : String(v));
|
||||
}
|
||||
for (const kid of kids.flat(Infinity)) {
|
||||
if (kid === null || kid === undefined || kid === false) continue;
|
||||
el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; };
|
||||
|
||||
const ICONS = {
|
||||
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
|
||||
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
||||
check: 'M20 6L9 17l-5-5',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
back: 'M15 18l-6-6 6-6',
|
||||
search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM21 21l-4.3-4.3',
|
||||
external: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
panel: 'M4 5h16v14H4zM14 5v14',
|
||||
send: 'M22 2L11 13M22 2l-7 20-4-9-9-4z',
|
||||
note: 'M4 4h16v12H8l-4 4zM8 9h8M8 12h5',
|
||||
clock: 'M12 7v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z',
|
||||
eye: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z',
|
||||
user: 'M20 21a8 8 0 0 0-16 0M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
|
||||
refresh: 'M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5',
|
||||
clip: 'M21 12.5l-8.5 8.5a5.5 5.5 0 0 1-8-8L13 4.5a3.5 3.5 0 0 1 5 5l-8.5 8.5a1.5 1.5 0 0 1-2-2L15 8',
|
||||
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||
warn: 'M12 3l10 18H2zM12 10v5M12 18h.01',
|
||||
x: 'M18 6L6 18M6 6l12 12',
|
||||
};
|
||||
|
||||
/** Symbol als SVG (nur feste Pfade aus der Liste oben). */
|
||||
export function icon(name, size = 18) {
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(ns, 'svg');
|
||||
for (const [k, v] of Object.entries({ viewBox: '0 0 24 24', width: size, height: size, fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, String(v));
|
||||
const p = document.createElementNS(ns, 'path');
|
||||
p.setAttribute('d', ICONS[name] || ICONS.menu);
|
||||
svg.append(p);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export const errText = (e) => (e && e.message) || 'Es ist ein Fehler aufgetreten.';
|
||||
|
||||
let toastTimer;
|
||||
/** Kurze Meldung unten im Fenster. */
|
||||
export function toast(text, kind = 'ok') {
|
||||
const host = document.getElementById('toasts');
|
||||
if (!host) return;
|
||||
clear(host).append(h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : 'status' }, text));
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => clear(host), kind === 'err' ? 6000 : 2600);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Gemeinsame Bausteine der Ansichten.
|
||||
import { h, icon, clear, errText } from './dom.js';
|
||||
import { STATUS_LABELS, safeColor, timeAgo, initials } from './core.js';
|
||||
|
||||
export const spinner = () => h('div', { class: 'spinner', role: 'status', 'aria-label': 'Lädt' });
|
||||
|
||||
export const statusPill = (status) => h('span', { class: `pill ${status}` }, STATUS_LABELS[status] || status);
|
||||
export const prioPill = (p) => (p ? h('span', { class: 'pill prio', style: { background: safeColor(p.color) || '#64748b' } }, p.name) : null);
|
||||
export const avatar = (name, cust = false) => h('span', { class: `avatar${cust ? ' cust' : ''}`, title: name || '' }, initials(name));
|
||||
|
||||
/** Eine Ticketzeile. onOpen(ticket) wird beim Klick aufgerufen. */
|
||||
export function ticketCard(t, onOpen) {
|
||||
const color = safeColor(t.priority?.color) || 'var(--muted)';
|
||||
const due = t.isOverdue ? h('span', { class: 'over' }, t.dueLabel || 'überfällig') : (t.dueLabel ? h('span', {}, t.dueLabel) : null);
|
||||
return h('li', {}, h('button', { class: `tk${t.isOverdue ? ' is-over' : ''}`, onclick: () => onOpen(t), title: `Ticket ${t.number} öffnen` },
|
||||
h('span', { class: 'bar', style: { background: color } }),
|
||||
h('div', { class: 'row1' }, h('div', { class: 'subj' }, t.subject)),
|
||||
h('div', { class: 'row2 meta' }, statusPill(t.status), h('span', {}, `#${t.number}`), h('span', {}, t.requesterName || '—'), due),
|
||||
h('div', { class: 'row2 meta' }, h('span', {}, t.department?.name || ''), t.assignedStaff ? h('span', {}, `· ${t.assignedStaff.name}`) : h('span', {}, '· nicht zugewiesen'), h('span', {}, `· ${timeAgo(t.updatedAt)}`),
|
||||
t.following ? icon('eye', 12) : null, t.snoozedUntil ? icon('clock', 12) : null)));
|
||||
}
|
||||
|
||||
export function ticketList(items, onOpen, emptyText = 'Keine Tickets.') {
|
||||
if (!items.length) return h('div', { class: 'empty' }, emptyText);
|
||||
return h('ul', { class: 'tickets' }, items.map((t) => ticketCard(t, onOpen)));
|
||||
}
|
||||
|
||||
/** Zeigt einen Fehler im Container; bei abgelaufener Anmeldung zurück zur Anmeldung. */
|
||||
export function showError(ctx, host, e) {
|
||||
if (e && e.status === 401) { ctx.logoutExpired(); return; }
|
||||
clear(host).append(h('div', { class: 'msg err', role: 'alert' }, errText(e)), h('div', { class: 'actions', style: { marginTop: '10px' } }, h('button', { class: 'btn small', onclick: () => ctx.reload() }, icon('refresh', 14), 'Erneut versuchen')));
|
||||
}
|
||||
|
||||
/** Ein Segmentschalter: options = [[wert, beschriftung], …] */
|
||||
export function segmented(options, current, onPick) {
|
||||
return h('div', { class: 'seg', role: 'group' }, options.map(([v, label]) => h('button', { type: 'button', class: v === current ? 'on' : '', onclick: () => onPick(v) }, label)));
|
||||
}
|
||||
|
||||
export function field(label, control) { return h('label', {}, label, control); }
|
||||
@@ -1,18 +1,24 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "DeskHub",
|
||||
"version": "1.0.0",
|
||||
"description": "DeskHub in der Browserleiste: Zähler und Benachrichtigungen für neue Tickets, Suche und schnell ein Ticket anlegen.",
|
||||
"version": "2.0.0",
|
||||
"description": "DeskHub in der Browserleiste: Tickets lesen und beantworten, Aufgaben, Status, Benachrichtigungen und schnell ein Ticket anlegen.",
|
||||
"minimum_chrome_version": "116",
|
||||
"icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" },
|
||||
"action": {
|
||||
"default_title": "DeskHub",
|
||||
"default_popup": "popup.html",
|
||||
"default_popup": "app.html",
|
||||
"default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png" }
|
||||
},
|
||||
"side_panel": { "default_path": "app.html?mode=panel" },
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"permissions": ["storage", "alarms", "notifications", "contextMenus"],
|
||||
"optional_host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||
"omnibox": { "keyword": "dh" },
|
||||
"commands": {
|
||||
"quick-create": { "suggested_key": { "default": "Alt+Shift+N" }, "description": "Neues DeskHub-Ticket anlegen" },
|
||||
"open-panel": { "suggested_key": { "default": "Alt+Shift+D" }, "description": "DeskHub-Seitenleiste öffnen" }
|
||||
},
|
||||
"permissions": ["storage", "alarms", "notifications", "contextMenus", "sidePanel"],
|
||||
"host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; connect-src https: http://localhost:* http://127.0.0.1:*"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('.', import.meta.url));
|
||||
const out = process.argv[2] || join(root, '..', 'release');
|
||||
const include = ['manifest.json', 'background.js', 'popup.html', 'popup.css', 'popup.js', 'lib', 'icons'];
|
||||
const include = ['manifest.json', 'background.js', 'app.html', 'app.css', 'app.js', 'lib', 'views', 'icons'];
|
||||
|
||||
const files = [];
|
||||
(function walk(p) {
|
||||
|
||||
@@ -91,7 +91,7 @@ test('apiCall: 401 räumt die Anmeldung weg, Netzwerkfehler wird gemeldet', asyn
|
||||
assert.notEqual(await getSession(), null, 'ein Netzwerkfehler meldet nicht ab');
|
||||
});
|
||||
|
||||
test('Anmeldung: Berechtigung, Authenticator-Code, Speichern', async () => {
|
||||
test('Anmeldung: ohne Rückfrage, Authenticator-Code, Speichern', async () => {
|
||||
const { login, getSession } = await fresh();
|
||||
let step = 0;
|
||||
globalThis.fetch = makeFetch({ 'POST login.php': (u, init) => {
|
||||
@@ -103,33 +103,52 @@ test('Anmeldung: Berechtigung, Authenticator-Code, Speichern', async () => {
|
||||
} });
|
||||
const r1 = await login('support.example.de', 'a@b.de', 'geheim', '');
|
||||
assert.deepEqual(r1, { requiresTotp: true });
|
||||
assert.deepEqual(env.calls.permissionsRequested, ['https://support.example.de/*'], 'nur dieser eine Server');
|
||||
assert.deepEqual(env.calls.permissionsRequested, [], 'es gibt keine Berechtigungsabfrage mehr');
|
||||
assert.equal(await getSession(), null, 'ohne Code noch keine Anmeldung');
|
||||
const r2 = await login('support.example.de', 'a@b.de', 'geheim', '123456');
|
||||
assert.equal(r2.session.staff.name, 'Agent');
|
||||
assert.equal((await getSession()).serverUrl, SERVER);
|
||||
});
|
||||
|
||||
test('Anmeldung ohne erteilte Berechtigung bricht ab, ohne etwas zu senden', async () => {
|
||||
test('Anmeldung mit falscher Adresse bricht ab, ohne etwas zu senden', async () => {
|
||||
const { login } = await fresh();
|
||||
env.chromeMock.permissions.allow = false;
|
||||
globalThis.fetch = makeFetch({});
|
||||
await assert.rejects(login('support.example.de', 'a@b.de', 'x', ''), /Erlaubnis/);
|
||||
await assert.rejects(login('http://support.example.de', 'a@b.de', 'x', ''), /HTTPS/);
|
||||
assert.equal(globalThis.fetch.log.length, 0);
|
||||
});
|
||||
|
||||
test('Abmelden sperrt das Token, entfernt Berechtigung und Daten', async () => {
|
||||
test('Abmelden sperrt das Token und entfernt die Daten', async () => {
|
||||
const { logout, setSession, getSession } = await fresh();
|
||||
await setSession(SESSION); await chrome.storage.local.set({ badgeCount: 3, lastSeen: 'x' });
|
||||
env.chromeMock.permissions.granted.add(SERVER + '/*');
|
||||
globalThis.fetch = makeFetch({ 'POST logout.php': { body: { ok: true } } });
|
||||
await logout();
|
||||
assert.equal(globalThis.fetch.log[0].key, 'POST logout.php');
|
||||
assert.deepEqual(env.calls.permissionsRemoved, [SERVER + '/*']);
|
||||
assert.equal(await getSession(), null);
|
||||
assert.deepEqual(await chrome.storage.local.get(['badgeCount', 'lastSeen']), {});
|
||||
});
|
||||
|
||||
test('apiBeacon: Anfrage geht sofort und mit keepalive raus (Popup schließt sich)', async () => {
|
||||
const { apiBeacon } = await fresh();
|
||||
globalThis.fetch = makeFetch({ 'POST ticket_unlock.php': { body: { ok: true } } });
|
||||
apiBeacon(SESSION, 'ticket_unlock.php', { number: 'T-1' });
|
||||
assert.equal(globalThis.fetch.log.length, 1, 'synchron abgeschickt, ohne auf Speicherzugriffe zu warten');
|
||||
const { init } = globalThis.fetch.log[0];
|
||||
assert.equal(init.keepalive, true); assert.equal(init.method, 'POST'); assert.equal(init.credentials, 'omit');
|
||||
assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`);
|
||||
assert.deepEqual(JSON.parse(init.body), { number: 'T-1' });
|
||||
apiBeacon(null, 'ticket_unlock.php', {});
|
||||
assert.equal(globalThis.fetch.log.length, 1, 'ohne Sitzung wird nichts gesendet');
|
||||
});
|
||||
|
||||
test('Abmelden löscht auch nicht gesendete Entwürfe, aber nicht die Einstellungen', async () => {
|
||||
const { clearSession, setSession, setSettings } = await fresh();
|
||||
await setSession(SESSION); await setSettings({ theme: 'dark' });
|
||||
await chrome.storage.local.set({ 'draft:T-1': 'halber Text', 'draft:T-2': 'mehr', lastLogin: { serverUrl: SERVER } });
|
||||
await clearSession();
|
||||
const left = await chrome.storage.local.get(null);
|
||||
assert.deepEqual(Object.keys(left).sort(), ['lastLogin', 'settings']);
|
||||
});
|
||||
|
||||
test('Ticketpfad und Textumwandlung', async () => {
|
||||
const { ticketPath, plainToHtml } = await fresh();
|
||||
assert.equal(ticketPath('T-2026-0001'), '/staff/ticket_view.php?n=T-2026-0001');
|
||||
@@ -217,12 +236,53 @@ test('Hintergrund: abgelaufene Anmeldung zeigt „!“ und bittet um neue Anmeld
|
||||
assert.equal(r.skipped, true); assert.equal(globalThis.fetch.log.length, 0);
|
||||
});
|
||||
|
||||
test('Manifest: nur nötige Rechte, kein Zugriff auf alle Webseiten ab Start, strenge Inhaltsrichtlinie', async () => {
|
||||
test('Hintergrund: Meldungsarten lassen sich einzeln abschalten', async () => {
|
||||
const { core, bg } = await freshBackground();
|
||||
await core.setSession(SESSION); await core.setSettings({ notifyUnassigned: false });
|
||||
await chrome.storage.local.set({ lastSeen: 'x' });
|
||||
globalThis.fetch = makeFetch({ 'GET activity.php': { body: { newReplies: 2, newUnassigned: 7, serverTime: 'y' } } });
|
||||
const r = await bg.poll();
|
||||
assert.equal(r.fresh, 2, 'nicht zugewiesene Tickets zählen nicht, wenn abgeschaltet');
|
||||
assert.match(env.calls.notifications[0].message, /2 Aktualisierungen/);
|
||||
assert.doesNotMatch(env.calls.notifications[0].message, /nicht zugewiesen/);
|
||||
});
|
||||
|
||||
test('Zeitangaben: Serverzeit lesen und kurz anzeigen', async () => {
|
||||
const { parseServerTime, timeAgo, fmtBytes, initials, safeColor } = await fresh();
|
||||
const now = new Date(2026, 9, 31, 12, 0, 0);
|
||||
assert.equal(parseServerTime('2026-10-31 04:03:00').getHours(), 4);
|
||||
assert.equal(parseServerTime('unsinn'), null);
|
||||
assert.equal(timeAgo('2026-10-31 11:59:30', now), 'gerade eben');
|
||||
assert.equal(timeAgo('2026-10-31 11:40:00', now), 'vor 20 Min');
|
||||
assert.equal(timeAgo('2026-10-31 04:03:00', now), 'heute 04:03');
|
||||
assert.equal(timeAgo('2026-10-30 23:33:00', now), 'gestern 23:33');
|
||||
assert.equal(timeAgo('2026-10-20 08:05:00', now), '20.10. 08:05');
|
||||
assert.equal(timeAgo('', now), '');
|
||||
assert.equal(timeAgo('2026-10-31 17:00:00', now), 'heute 17:00', 'Zukunft: Wiedervorlage');
|
||||
assert.equal(timeAgo('2026-11-01 08:00:00', now), 'morgen 08:00');
|
||||
assert.equal(timeAgo('2026-11-05 08:00:00', now), '05.11. 08:00');
|
||||
assert.equal(fmtBytes(500), '500 B'); assert.equal(fmtBytes(2048), '2 KB'); assert.equal(fmtBytes(3 * 1048576), '3.0 MB');
|
||||
assert.equal(initials('Lukas Muster'), 'LM'); assert.equal(initials(''), '?');
|
||||
assert.equal(safeColor('#0f766e'), '#0f766e'); assert.equal(safeColor('red;background:url(x)'), null);
|
||||
});
|
||||
|
||||
test('Wiedervorlage: nur künftige Zeitpunkte, im Serverformat', async () => {
|
||||
globalThis.document = undefined;
|
||||
const { snoozeChoices } = await import('../views/ticket.js');
|
||||
const now = new Date(2026, 9, 31, 18, 0, 0); // nach 17 Uhr: „Heute 17:00“ entfällt
|
||||
const labels = snoozeChoices(now).map(([l]) => l);
|
||||
assert.ok(!labels.includes('Heute 17:00')); assert.ok(labels.includes('Morgen 08:00')); assert.ok(labels.includes('In 1 Stunde'));
|
||||
for (const [, v] of snoozeChoices(now)) assert.match(v, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:00$/);
|
||||
assert.deepEqual(snoozeChoices(now).find(([l]) => l === 'Morgen 08:00'), ['Morgen 08:00', '2026-11-01 08:00:00']);
|
||||
});
|
||||
|
||||
test('Manifest: Serverzugriff ohne Rückfrage, nur nötige Rechte, strenge Inhaltsrichtlinie', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const m = JSON.parse(await readFile(new URL('../manifest.json', import.meta.url), 'utf8'));
|
||||
assert.equal(m.manifest_version, 3);
|
||||
assert.deepEqual([...m.permissions].sort(), ['alarms', 'contextMenus', 'notifications', 'storage']);
|
||||
assert.equal(m.host_permissions, undefined, 'Serverzugriff wird erst bei der Anmeldung für genau einen Server erfragt');
|
||||
assert.deepEqual([...m.permissions].sort(), ['alarms', 'contextMenus', 'notifications', 'sidePanel', 'storage']);
|
||||
assert.equal(m.optional_host_permissions, undefined, 'keine nachträgliche Berechtigungsabfrage');
|
||||
assert.ok(m.host_permissions.includes('https://*/*'), 'Zugriff auf den Server ist von Anfang an erlaubt');
|
||||
assert.match(m.content_security_policy.extension_pages, /script-src 'self'/);
|
||||
assert.doesNotMatch(m.content_security_policy.extension_pages, /unsafe-inline|unsafe-eval/);
|
||||
for (const f of Object.values(m.icons)) await readFile(new URL('../' + f, import.meta.url));
|
||||
|
||||
@@ -38,7 +38,7 @@ test('Anmeldung mit richtigem Passwort', { skip }, async () => {
|
||||
session = r.session;
|
||||
assert.equal(session.staff.email, EMAIL);
|
||||
assert.ok(session.token.length >= 40);
|
||||
assert.ok([...granted].includes(new URL(URL_).origin + '/*'), 'Berechtigung nur für diesen Server');
|
||||
assert.equal(granted.size, 0, 'keine Berechtigungsabfrage');
|
||||
assert.equal((await core.getSession()).token, session.token);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,36 @@ test('Text mit Sonderzeichen: kommt als Klartext unverändert zurück (gespeiche
|
||||
assert.equal(ticket.threads[0].message, 'Zeile 1\nZeile 2 <script>alert(1)</script> & "Anführungszeichen"');
|
||||
});
|
||||
|
||||
test('Ticket bearbeiten wie in der Oberfläche: antworten, Notiz, Wiedervorlage, Beobachten, Status, Aufgaben', { skip }, async () => {
|
||||
const number = created.number;
|
||||
const call = (path, body, method = 'POST') => core.apiCall(path, { method, body });
|
||||
await call('ticket_message.php', { number, type: 'reply', message: core.plainToHtml('Antwort <b>1</b>'), setStatus: 'pending' });
|
||||
await call('ticket_message.php', { number, type: 'note', message: core.plainToHtml('Interne Notiz') });
|
||||
let t = (await core.apiCall('ticket.php', { query: { number } })).data.ticket;
|
||||
assert.equal(t.status, 'pending');
|
||||
const kinds = t.threads.map((m) => (m.isInternalNote ? 'note' : m.authorType));
|
||||
assert.deepEqual(kinds.slice(-2), ['staff', 'note']);
|
||||
assert.equal(t.threads.at(-2).message, 'Antwort <b>1</b>', 'Sonderzeichen bleiben Text');
|
||||
const soon = new Date(Date.now() + 3 * 3600 * 1000); const pad = (n) => String(n).padStart(2, '0');
|
||||
const until = `${soon.getFullYear()}-${pad(soon.getMonth() + 1)}-${pad(soon.getDate())} ${pad(soon.getHours())}:${pad(soon.getMinutes())}:00`;
|
||||
await call('ticket_update.php', { number, snoozeUntil: until, following: true });
|
||||
t = (await core.apiCall('ticket.php', { query: { number } })).data.ticket;
|
||||
assert.ok(t.snoozedUntil && t.following);
|
||||
await call('ticket_update.php', { number, snoozeUntil: null, following: false });
|
||||
await assert.rejects(call('ticket_update.php', { number, status: 'closed' }), (e) => e.code === 'RESOLUTION_CODE_REQUIRED');
|
||||
await call('ticket_update.php', { number, status: 'closed', resolutionCode: 'fixed' });
|
||||
t = (await core.apiCall('ticket.php', { query: { number } })).data.ticket;
|
||||
assert.equal(t.status, 'closed'); assert.equal(t.snoozedUntil, null); assert.equal(t.following, false);
|
||||
await call('ticket_unlock.php', { number });
|
||||
const task = (await call('tasks.php', { action: 'create', title: 'Aufgabe aus Test', ticketNumber: number })).data;
|
||||
await call('tasks.php', { action: 'update_status', id: task.id, status: 'done' });
|
||||
const items = (await core.apiCall('tasks.php', { query: { filter: 'done' } })).data.items;
|
||||
assert.ok(items.some((i) => i.id === task.id && i.ticketNumber === number));
|
||||
await call('tasks.php', { action: 'delete', id: task.id });
|
||||
assert.equal((await core.apiCall('presence.php', { method: 'POST', body: {} })).data.ok, true);
|
||||
const av = (await core.apiCall('availability.php')).data; assert.ok(Array.isArray(av.team));
|
||||
});
|
||||
|
||||
test('Ticket im Browser öffnen: einmalige Anmelde-Adresse des eigenen Servers', { skip }, async () => {
|
||||
tabs.length = 0;
|
||||
await core.openWeb(core.ticketPath(created.number));
|
||||
@@ -105,7 +135,6 @@ test('Abmelden sperrt das Token auf dem Server', { skip }, async () => {
|
||||
const token = (await core.getSession()).token;
|
||||
await core.logout();
|
||||
assert.equal(await core.getSession(), null);
|
||||
assert.equal(granted.size, 0, 'Berechtigung wurde zurückgegeben');
|
||||
const res = await fetch(`${URL_}/api/staff/v1/me.php`, { headers: { Authorization: `Bearer ${token}`, 'X-DeskHub-Authorization': `Bearer ${token}` } });
|
||||
assert.equal(res.status, 401, 'das alte Token gilt nicht mehr');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Neues Ticket anlegen (auch aus dem Rechtsklick-Menü und per Tastenkürzel mit vorbefülltem Text).
|
||||
import { h, clear, toast, errText } from '../lib/dom.js';
|
||||
import { apiCall, getMeta, plainToHtml } from '../lib/core.js';
|
||||
import { spinner, showError } from '../lib/ui.js';
|
||||
|
||||
export async function renderCreate(host, ctx) {
|
||||
host.append(spinner());
|
||||
let meta;
|
||||
try { meta = await getMeta(); } catch (e) { return showError(ctx, host, e); }
|
||||
const { prefill } = await chrome.storage.local.get('prefill');
|
||||
if (prefill) chrome.storage.local.remove('prefill');
|
||||
clear(host);
|
||||
const me = ctx.session.staff?.id;
|
||||
const f = {
|
||||
name: h('input', { type: 'text', required: true, maxlength: '120', placeholder: 'Name des Anfragers' }),
|
||||
mail: h('input', { type: 'email', placeholder: 'optional' }),
|
||||
subject: h('input', { type: 'text', value: prefill?.subject || '', required: true, maxlength: '255' }),
|
||||
message: h('textarea', { required: true, value: prefill?.message || '' }),
|
||||
dept: h('select', { required: true }, meta.departments.map((d) => h('option', { value: d.id }, d.name))),
|
||||
prio: h('select', { required: true }, meta.priorities.map((p) => h('option', { value: p.id }, p.name))),
|
||||
who: h('select', {}, h('option', { value: '' }, 'Nicht zuweisen'), h('option', { value: me, selected: '' }, 'Mir zuweisen'), meta.agents.filter((a) => a.id !== me).map((a) => h('option', { value: a.id }, a.name))),
|
||||
notify: h('input', { type: 'checkbox' }),
|
||||
};
|
||||
const submit = h('button', { class: 'btn primary', type: 'submit' }, 'Ticket anlegen');
|
||||
const out = h('div');
|
||||
host.append(h('form', { class: 'stack', onsubmit: async (ev) => {
|
||||
ev.preventDefault(); clear(out); submit.disabled = true;
|
||||
try {
|
||||
const { data } = await apiCall('ticket_create.php', { method: 'POST', body: {
|
||||
requesterName: f.name.value.trim(), requesterEmail: f.mail.value.trim(), subject: f.subject.value.trim(), message: plainToHtml(f.message.value.trim()),
|
||||
departmentId: Number(f.dept.value), priorityId: Number(f.prio.value), assignedStaffId: f.who.value ? Number(f.who.value) : null, notifyCustomer: f.notify.checked } });
|
||||
const number = data.ticket?.number;
|
||||
toast(`Ticket #${number} angelegt`);
|
||||
if (number) ctx.go('ticket', { number });
|
||||
} catch (e) { if (e.status === 401) return ctx.logoutExpired(); out.append(h('div', { class: 'msg err', role: 'alert' }, errText(e))); }
|
||||
submit.disabled = false;
|
||||
} },
|
||||
h('div', { class: 'card stack' },
|
||||
h('label', {}, 'Anfrager (Name)', f.name), h('label', {}, 'Anfrager (E-Mail)', f.mail),
|
||||
h('label', {}, 'Betreff', f.subject), h('label', {}, 'Beschreibung', f.message),
|
||||
h('div', { class: 'row2' }, h('label', {}, 'Abteilung', f.dept), h('label', {}, 'Priorität', f.prio)),
|
||||
h('label', {}, 'Bearbeiter', f.who),
|
||||
h('label', { class: 'inline' }, f.notify, 'Kunden per E-Mail benachrichtigen')),
|
||||
out, submit));
|
||||
f.name.focus();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Start: Begrüßung, eigener Status, Kennzahlen, als Nächstes fällige Tickets.
|
||||
import { h, clear, icon, toast, errText } from '../lib/dom.js';
|
||||
import { apiCall, AVAILABILITY } from '../lib/core.js';
|
||||
import { spinner, ticketList, showError, segmented } from '../lib/ui.js';
|
||||
|
||||
export async function renderHome(host, ctx) {
|
||||
host.append(spinner());
|
||||
try {
|
||||
const [dash, avail] = await Promise.all([
|
||||
apiCall('dashboard.php').then((r) => r.data),
|
||||
apiCall('availability.php').then((r) => r.data).catch(() => null),
|
||||
]);
|
||||
clear(host);
|
||||
const first = (ctx.session.staff?.name || '').split(' ')[0];
|
||||
const stack = h('div', { class: 'stack' });
|
||||
stack.append(h('div', {}, h('div', { class: 'muted' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })), h('div', { class: 'hello' }, `Hallo ${first || ''}`.trim())));
|
||||
|
||||
if (avail) {
|
||||
let current = avail.myStatus;
|
||||
const holder = h('div');
|
||||
const draw = () => holder.replaceChildren(segmented(Object.entries(AVAILABILITY), current, async (v) => {
|
||||
const old = current; current = v; draw();
|
||||
try { await apiCall('availability.php', { method: 'POST', body: { status: v, note: avail.myNote || '' } }); ctx.setStatusDot(v); toast(`Status: ${AVAILABILITY[v]}`); }
|
||||
catch (e) { current = old; draw(); toast(errText(e), 'err'); }
|
||||
}));
|
||||
draw();
|
||||
ctx.setStatusDot(current);
|
||||
stack.append(h('div', { class: 'card' }, h('h2', {}, 'Mein Status'), holder));
|
||||
}
|
||||
|
||||
const stat = (value, label, cls, filter) => h('button', { class: `stat ${cls || ''}`, onclick: () => ctx.go('tickets', { filter }) }, h('div', { class: 'v' }, value), h('div', { class: 'k' }, label));
|
||||
stack.append(h('div', { class: 'stats' },
|
||||
stat(dash.myOpen ?? 0, 'Meine offenen', '', 'mine'),
|
||||
stat(dash.overdue ?? 0, 'Überfällig', dash.overdue ? 'warn' : '', 'overdue'),
|
||||
stat(dash.unassigned ?? 0, 'Nicht zugewiesen', '', 'unassigned'),
|
||||
stat(dash.unanswered ?? 0, 'Unbeantwortet', '', 'open_status')),
|
||||
h('div', { class: 'stats' },
|
||||
h('div', { class: 'stat good' }, h('div', { class: 'v' }, dash.resolvedToday ?? 0), h('div', { class: 'k' }, 'Heute gelöst')),
|
||||
h('div', { class: 'stat' }, h('div', { class: 'v' }, dash.slaPercent == null ? '–' : `${Math.round(dash.slaPercent)} %`), h('div', { class: 'k' }, 'SLA eingehalten (30 Tage)'))));
|
||||
|
||||
stack.append(h('h2', { class: 'sec', style: { margin: '6px 0 0' } }, 'Als Nächstes fällig'), ticketList(dash.recent || [], (t) => ctx.go('ticket', { number: t.number }), 'Alles erledigt. Nichts offen.'));
|
||||
host.append(stack);
|
||||
} catch (e) { showError(ctx, host, e); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Anmeldung. Kein Berechtigungsschritt mehr: Der Serverzugriff ist im Manifest erlaubt.
|
||||
import { h, errText } from '../lib/dom.js';
|
||||
import { login, normalizeServerUrl } from '../lib/core.js';
|
||||
|
||||
export async function renderLogin(app, ctx, prefill = {}) {
|
||||
const { lastLogin } = await chrome.storage.local.get('lastLogin');
|
||||
const start = { serverUrl: prefill.serverUrl ?? lastLogin?.serverUrl ?? '', email: prefill.email ?? lastLogin?.email ?? '', needTotp: !!prefill.needTotp, error: prefill.error || '' };
|
||||
let password = prefill.password || '';
|
||||
const msg = h('div', { class: 'msg err', hidden: start.error ? undefined : true, role: 'alert' }, start.error);
|
||||
const server = h('input', { type: 'text', id: 'server', placeholder: 'support.example.de', value: start.serverUrl, autocomplete: 'url', required: true });
|
||||
const email = h('input', { type: 'email', id: 'email', value: start.email, autocomplete: 'username', required: true });
|
||||
const pass = h('input', { type: 'password', id: 'pass', autocomplete: 'current-password', required: !start.needTotp });
|
||||
const code = h('input', { type: 'text', id: 'code', inputmode: 'numeric', pattern: '[0-9]{6}', maxlength: '6', autocomplete: 'one-time-code', placeholder: '6-stelliger Code' });
|
||||
const button = h('button', { class: 'btn primary', type: 'submit' }, start.needTotp ? 'Bestätigen' : 'Anmelden');
|
||||
const form = h('form', {
|
||||
onsubmit: async (ev) => {
|
||||
ev.preventDefault(); msg.hidden = true; button.disabled = true;
|
||||
try {
|
||||
if (!start.needTotp) password = pass.value;
|
||||
const r = await login(server.value, email.value.trim(), password, start.needTotp ? code.value.trim() : '');
|
||||
if (r.requiresTotp) return renderLogin(app, ctx, { serverUrl: server.value, email: email.value, password, needTotp: true });
|
||||
await chrome.storage.local.set({ lastLogin: { serverUrl: normalizeServerUrl(server.value), email: email.value.trim() } });
|
||||
password = '';
|
||||
await chrome.runtime.sendMessage({ type: 'session-changed' }).catch(() => {});
|
||||
ctx.loggedIn(r.session);
|
||||
} catch (e) { msg.textContent = errText(e); msg.hidden = false; button.disabled = false; }
|
||||
},
|
||||
},
|
||||
start.needTotp
|
||||
? [h('p', { class: 'muted' }, 'Dein Konto ist mit einem Authenticator geschützt. Bitte den aktuellen Code eingeben.'), h('label', {}, 'Authenticator-Code', code)]
|
||||
: [h('label', {}, 'Server-Adresse', server), h('label', {}, 'E-Mail-Adresse', email), h('label', {}, 'Passwort', pass)],
|
||||
msg, button);
|
||||
app.replaceChildren(h('div', { class: 'content' }, h('div', { class: 'login' },
|
||||
h('div', { class: 'brand' }, h('img', { src: 'icons/icon128.png', alt: '' }), h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mit deinem Mitarbeiter-Konto anmelden')),
|
||||
form)));
|
||||
(start.needTotp ? code : (start.serverUrl ? pass : server)).focus();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Mehr: Konto, Team, Einstellungen, Weboberfläche, Abmelden.
|
||||
import { h, clear, icon, toast, errText } from '../lib/dom.js';
|
||||
import { apiCall, getSettings, setSettings, openWeb, AVAILABILITY } from '../lib/core.js';
|
||||
import { spinner, segmented, avatar } from '../lib/ui.js';
|
||||
|
||||
const toggle = (label, hint, checked, onChange) => h('label', { class: 'listrow', style: { fontWeight: 500 } },
|
||||
h('span', { class: 'grow' }, h('div', {}, label), hint ? h('div', { class: 'muted' }, hint) : null),
|
||||
h('span', { class: 'switch' }, h('input', { type: 'checkbox', checked, onchange: (ev) => onChange(ev.target.checked) })));
|
||||
|
||||
export async function renderMore(host, ctx) {
|
||||
const settings = await getSettings();
|
||||
const s = ctx.session.staff || {};
|
||||
const save = async (patch) => { Object.assign(settings, patch); await setSettings(settings); ctx.applyTheme(settings.theme); };
|
||||
const team = h('div', {}, spinner());
|
||||
|
||||
host.append(h('div', { class: 'stack' },
|
||||
h('div', { class: 'card', style: { display: 'flex', gap: '12px', alignItems: 'center' } }, avatar(s.name), h('div', { class: 'grow' }, h('div', { style: { fontWeight: 700 } }, s.name), h('div', { class: 'muted' }, `${s.email || ''} · ${s.role === 'admin' ? 'Administrator' : 'Mitarbeiter'}`), h('div', { class: 'muted' }, new URL(ctx.session.serverUrl).host))),
|
||||
|
||||
h('div', { class: 'card' }, h('h2', {}, 'Benachrichtigungen'),
|
||||
toggle('Meldungen anzeigen', 'Chrome-Benachrichtigung bei Neuem', settings.notifications, (v) => save({ notifications: v })),
|
||||
toggle('Neue, nicht zugewiesene Tickets', null, settings.notifyUnassigned, (v) => save({ notifyUnassigned: v })),
|
||||
toggle('Aktualisierungen bei meinen Tickets', null, settings.notifyMine, (v) => save({ notifyMine: v }))),
|
||||
|
||||
h('div', { class: 'card' }, h('h2', {}, 'Darstellung'),
|
||||
segmented([['auto', 'Automatisch'], ['light', 'Hell'], ['dark', 'Dunkel']], settings.theme, async (v) => { await save({ theme: v }); clear(host); renderMore(host, ctx); })),
|
||||
|
||||
h('div', { class: 'card' }, h('h2', {}, 'Fenster'),
|
||||
h('div', { class: 'actions' },
|
||||
!ctx.isPanel ? h('button', { class: 'btn', onclick: ctx.openPanel }, icon('panel', 15), 'Seitenleiste') : null,
|
||||
!ctx.isWindow ? h('button', { class: 'btn', onclick: ctx.openWindow }, icon('external', 15), 'Eigenes Fenster') : null,
|
||||
h('button', { class: 'btn', onclick: () => openWeb('/staff/dashboard.php').catch((e) => toast(errText(e), 'err')) }, icon('home', 15), 'Weboberfläche')),
|
||||
h('div', { class: 'muted', style: { marginTop: '8px' } }, 'Tastenkürzel: Alt+Shift+N neues Ticket, Alt+Shift+D Seitenleiste. In der Adressleiste: „dh“ + Leertaste + Ticketnummer oder Suchbegriff.')),
|
||||
|
||||
h('div', { class: 'card' }, h('h2', {}, 'Team'), team),
|
||||
h('button', { class: 'btn danger', onclick: async () => { await ctx.logout(); } }, icon('logout', 15), 'Abmelden'),
|
||||
h('div', { class: 'muted', style: { textAlign: 'center' } }, `DeskHub-Erweiterung ${chrome.runtime.getManifest().version}`)));
|
||||
|
||||
try {
|
||||
const { data } = await apiCall('availability.php');
|
||||
clear(team).append(...(data.team || []).map((m) => h('div', { class: 'listrow' }, h('span', { class: `statusdot ${m.status}`, style: { boxShadow: 'none' } }), h('span', { class: 'grow' }, m.name, m.note ? h('span', { class: 'muted' }, ` · ${m.note}`) : null), h('span', { class: 'muted' }, AVAILABILITY[m.status] || m.status))));
|
||||
if (!data.team?.length) clear(team).append(h('div', { class: 'muted' }, 'Keine Daten.'));
|
||||
} catch { clear(team).append(h('div', { class: 'muted' }, 'Team konnte nicht geladen werden.')); }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Aufgaben: ansehen, abhaken, neue anlegen.
|
||||
import { h, clear, icon, toast, errText } from '../lib/dom.js';
|
||||
import { apiCall, parseServerTime } from '../lib/core.js';
|
||||
import { spinner, showError } from '../lib/ui.js';
|
||||
|
||||
const FILTERS = [['my', 'Meine'], ['open', 'Alle offenen'], ['done', 'Erledigt']];
|
||||
const memory = { filter: 'my' };
|
||||
|
||||
const dueText = (s) => { const d = parseServerTime(s); return d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; };
|
||||
|
||||
export async function renderTasks(host, ctx) {
|
||||
const list = h('div');
|
||||
const chips = h('div', { class: 'chips' });
|
||||
const form = h('div');
|
||||
|
||||
const drawChips = () => chips.replaceChildren(...FILTERS.map(([k, l]) => h('button', { type: 'button', class: `chip${memory.filter === k ? ' on' : ''}`, onclick: () => { memory.filter = k; drawChips(); load(); } }, l)));
|
||||
|
||||
async function load() {
|
||||
clear(list).append(spinner());
|
||||
try {
|
||||
const { data } = await apiCall('tasks.php', { query: { filter: memory.filter } });
|
||||
ctx.taskStaff = data.staffOptions || [];
|
||||
clear(list);
|
||||
if (!data.items.length) return list.append(h('div', { class: 'empty' }, memory.filter === 'done' ? 'Keine erledigten Aufgaben.' : 'Keine Aufgaben. Schön!'));
|
||||
list.append(h('div', { class: 'stack' }, data.items.map((it) => {
|
||||
const done = it.status === 'done';
|
||||
const box = h('input', { type: 'checkbox', checked: done, 'aria-label': 'Erledigt', onchange: async () => {
|
||||
try { await apiCall('tasks.php', { method: 'POST', body: { action: 'update_status', id: it.id, status: box.checked ? 'done' : 'open' } }); toast(box.checked ? 'Erledigt' : 'Wieder geöffnet'); load(); }
|
||||
catch (e) { box.checked = !box.checked; toast(errText(e), 'err'); }
|
||||
} });
|
||||
return h('div', { class: `card task${done ? ' done' : ''}` }, box, h('div', { class: 'grow' },
|
||||
h('div', { class: 'tt', style: { fontWeight: 650 } }, it.title),
|
||||
it.description ? h('div', { class: 'muted' }, it.description) : null,
|
||||
h('div', { class: 'muted', style: { marginTop: '3px' } }, [it.dueAt ? `fällig ${dueText(it.dueAt)}` : null, it.assignedName ? `für ${it.assignedName}` : null].filter(Boolean).join(' · ')),
|
||||
it.ticketNumber ? h('button', { class: 'btn small', style: { marginTop: '6px' }, onclick: () => ctx.go('ticket', { number: it.ticketNumber }) }, `#${it.ticketNumber}`) : null),
|
||||
h('button', { class: 'iconbtn', title: 'Aufgabe löschen', onclick: async () => {
|
||||
if (!confirm('Aufgabe löschen?')) return;
|
||||
try { await apiCall('tasks.php', { method: 'POST', body: { action: 'delete', id: it.id } }); load(); } catch (e) { toast(errText(e), 'err'); }
|
||||
} }, icon('x', 16)));
|
||||
})));
|
||||
} catch (e) { showError(ctx, list, e); }
|
||||
}
|
||||
|
||||
function drawForm(open) {
|
||||
if (!open) { form.replaceChildren(h('button', { class: 'btn', onclick: () => drawForm(true) }, icon('plus', 15), 'Neue Aufgabe')); return; }
|
||||
const title = h('input', { type: 'text', required: true, maxlength: '200', placeholder: 'Was ist zu tun?' });
|
||||
const due = h('input', { type: 'datetime-local' });
|
||||
const ticket = h('input', { type: 'text', placeholder: 'Ticketnummer (optional)' });
|
||||
const who = h('select', {}, h('option', { value: '' }, 'Niemand'), (ctx.taskStaff || []).map((s) => h('option', { value: s.id, selected: s.id === ctx.session.staff?.id ? '' : undefined }, s.name)));
|
||||
form.replaceChildren(h('form', { class: 'sheet', onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
await apiCall('tasks.php', { method: 'POST', body: { action: 'create', title: title.value.trim(), dueAt: due.value ? due.value.replace('T', ' ') + ':00' : '', ticketNumber: ticket.value.trim(), assignedToId: who.value ? Number(who.value) : null } });
|
||||
toast('Aufgabe angelegt'); drawForm(false); load();
|
||||
} catch (e) { toast(errText(e), 'err'); }
|
||||
} }, h('label', {}, 'Titel', title), h('div', { class: 'row2' }, h('label', {}, 'Fällig', due), h('label', {}, 'Für', who)), h('label', {}, 'Ticket', ticket),
|
||||
h('div', { class: 'actions' }, h('button', { class: 'btn primary', type: 'submit' }, 'Anlegen'), h('button', { class: 'btn', type: 'button', onclick: () => drawForm(false) }, 'Abbrechen'))));
|
||||
title.focus();
|
||||
}
|
||||
|
||||
host.append(h('div', { class: 'stack' }, form, chips, list));
|
||||
drawChips(); drawForm(false); load();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Ticket-Ansicht: Verlauf lesen, antworten, interne Notiz, Status, Zuweisung, Wiedervorlage, Beobachten.
|
||||
import { h, clear, icon, toast, errText } from '../lib/dom.js';
|
||||
import { apiCall, apiBeacon, getMeta, openWeb, ticketPath, plainToHtml, timeAgo, fmtBytes, RESOLUTION_REASONS, STATUS_LABELS } from '../lib/core.js';
|
||||
import { spinner, statusPill, prioPill, avatar } from '../lib/ui.js';
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const sqlTime = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
|
||||
/** Zeitpunkte für die Wiedervorlage (nur künftige). */
|
||||
export function snoozeChoices(now = new Date()) {
|
||||
const at = (days, hour) => { const d = new Date(now); d.setDate(d.getDate() + days); d.setHours(hour, 0, 0, 0); return d; };
|
||||
const list = [['In 1 Stunde', new Date(now.getTime() + 3600 * 1000)], ['Heute 17:00', at(0, 17)], ['Morgen 08:00', at(1, 8)], ['In 3 Tagen', at(3, 8)], ['In einer Woche', at(7, 8)]];
|
||||
return list.filter(([, d]) => d.getTime() > now.getTime() + 120 * 1000).map(([label, d]) => [label, sqlTime(d)]);
|
||||
}
|
||||
|
||||
export async function renderTicket(host, ctx, { number }) {
|
||||
host.append(spinner());
|
||||
let t;
|
||||
try { ({ data: { ticket: t } } = await apiCall('ticket.php', { query: { number } })); }
|
||||
catch (e) {
|
||||
clear(host);
|
||||
host.append(h('div', { class: 'msg err', role: 'alert' }, errText(e)), h('div', { class: 'actions', style: { marginTop: '10px' } },
|
||||
h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((x) => toast(errText(x), 'err')) }, icon('external', 14), 'Im Browser öffnen')));
|
||||
if (e.status === 401) ctx.logoutExpired();
|
||||
return;
|
||||
}
|
||||
// Wer das Ticket geöffnet und dabei die Sperre erhalten hat, gibt sie beim Verlassen wieder frei.
|
||||
if (!t.lockedBy && !t.lockedByOtherName) ctx.onLeave(() => apiBeacon(ctx.session, 'ticket_unlock.php', { number })); // läuft auch weiter, wenn sich das Popup schließt
|
||||
const meta = await getMeta().catch(() => ({ agents: [], departments: [], priorities: [] }));
|
||||
const reload = () => { clear(host); return renderTicket(host, ctx, { number }); };
|
||||
const perms = t.permissions || {};
|
||||
const me = ctx.session.staff?.id;
|
||||
|
||||
async function update(body, okText) {
|
||||
try { await apiCall('ticket_update.php', { method: 'POST', body: { number, ...body } }); toast(okText); await reload(); }
|
||||
catch (e) { toast(errText(e), 'err'); }
|
||||
}
|
||||
|
||||
// ─ Kopf
|
||||
const head = h('div', { class: 'card tdetail' },
|
||||
h('h1', {}, t.subject),
|
||||
h('div', { class: 'facts' }, statusPill(t.status), prioPill(t.priority), h('span', { class: 'pill' }, `#${t.number}`), t.isOverdue ? h('span', { class: 'pill', style: { background: 'var(--bad-bg)', color: 'var(--bad)' } }, t.dueLabel || 'überfällig') : (t.dueLabel ? h('span', { class: 'pill' }, t.dueLabel) : null),
|
||||
t.snoozedUntil ? h('span', { class: 'pill' }, icon('clock', 12), `Wiedervorlage: ${timeAgo(t.snoozedUntil)}`) : null),
|
||||
h('div', { class: 'muted', style: { marginTop: '8px' } },
|
||||
`${t.requesterName}${t.requesterEmail ? ` <${t.requesterEmail}>` : ''}`, h('br'),
|
||||
`${t.department?.name || ''} · ${t.assignedStaff ? `zugewiesen an ${t.assignedStaff.name}` : 'nicht zugewiesen'} · erstellt ${timeAgo(t.createdAt)}`));
|
||||
const notices = [];
|
||||
if (t.lockedBy) notices.push(h('div', { class: 'msg warn' }, `Wird gerade von ${t.lockedBy} bearbeitet (Admin-Zugriff).`));
|
||||
if (t.lockedByOtherName) notices.push(h('div', { class: 'msg warn' }, `Dieser Chat wird gerade von ${t.lockedByOtherName} bearbeitet. Du kannst zuschauen.`));
|
||||
if (t.isSpam) notices.push(h('div', { class: 'msg warn' }, 'Als Spam markiert.'));
|
||||
|
||||
// ─ Schnellaktionen
|
||||
const actions = h('div', { class: 'actions' });
|
||||
if (perms.assign_ticket && t.assignedStaff?.id !== me) actions.append(h('button', { class: 'btn small', onclick: () => update({ assignedStaffId: me }, 'Dir zugewiesen') }, icon('user', 14), 'Mir zuweisen'));
|
||||
actions.append(h('button', { class: 'btn small', onclick: () => update({ following: !t.following }, t.following ? 'Beobachten beendet' : 'Du beobachtest das Ticket') }, icon('eye', 14), t.following ? 'Nicht mehr beobachten' : 'Beobachten'));
|
||||
const snoozeSel = h('select', { 'aria-label': 'Wiedervorlage', onchange: (ev) => { const v = ev.target.value; if (!v) return; update({ snoozeUntil: v === 'clear' ? null : v }, v === 'clear' ? 'Wiedervorlage aufgehoben' : 'Wiedervorlage gesetzt'); } },
|
||||
h('option', { value: '' }, 'Wiedervorlage …'), snoozeChoices().map(([label, v]) => h('option', { value: v }, label)), t.snoozedUntil ? h('option', { value: 'clear' }, 'Aufheben') : null);
|
||||
actions.append(h('div', { style: { minWidth: '150px' } }, snoozeSel));
|
||||
actions.append(h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((e) => toast(errText(e), 'err')) }, icon('external', 14), 'Im Browser'));
|
||||
|
||||
// ─ Eigenschaften
|
||||
const statusSel = h('select', { 'aria-label': 'Status' }, Object.entries(STATUS_LABELS).map(([v, l]) => h('option', { value: v, selected: v === t.status ? '' : undefined }, l)));
|
||||
const reasonSel = h('select', { 'aria-label': 'Abschlussgrund' }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l)));
|
||||
const reasonRow = h('div', { hidden: true }, reasonSel);
|
||||
const syncReason = () => { reasonRow.hidden = !['resolved', 'closed'].includes(statusSel.value); };
|
||||
statusSel.addEventListener('change', syncReason);
|
||||
const assignSel = h('select', { 'aria-label': 'Zuweisen' }, h('option', { value: '' }, 'Niemand'), (meta.agents || []).map((a) => h('option', { value: a.id, selected: a.id === t.assignedStaff?.id ? '' : undefined }, a.name)));
|
||||
const props = h('div', { class: 'card' }, h('h2', {}, 'Eigenschaften'), h('div', { class: 'stack' },
|
||||
h('div', { class: 'row2' }, h('label', {}, 'Status', statusSel), perms.assign_ticket ? h('label', {}, 'Zugewiesen an', assignSel) : null),
|
||||
reasonRow,
|
||||
h('div', { class: 'actions' }, h('button', { class: 'btn small primary', onclick: async () => {
|
||||
const body = {};
|
||||
if (statusSel.value !== t.status) { body.status = statusSel.value; if (['resolved', 'closed'].includes(statusSel.value)) { if (!reasonSel.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = reasonSel.value; } }
|
||||
const wanted = assignSel.value === '' ? null : Number(assignSel.value);
|
||||
if (perms.assign_ticket && wanted !== (t.assignedStaff?.id ?? null)) body.assignedStaffId = wanted;
|
||||
if (!Object.keys(body).length) return toast('Nichts geändert.');
|
||||
update(body, 'Gespeichert');
|
||||
} }, 'Speichern'))));
|
||||
syncReason();
|
||||
|
||||
// ─ Verlauf
|
||||
const thread = h('div', { class: 'stack' }, (t.threads || []).map((m) => {
|
||||
const kind = m.isInternalNote ? 'note' : m.authorType === 'system' ? 'system' : m.authorType === 'staff' ? 'staff' : 'cust';
|
||||
return h('div', { class: 'thread' }, h('div', { class: `bubble ${kind}` },
|
||||
h('div', { class: 'who' }, kind === 'system' ? null : avatar(m.authorName, kind === 'cust'), h('span', {}, m.authorName), kind === 'note' ? h('span', { class: 'tag' }, 'Interne Notiz') : null, h('span', { class: 'when' }, timeAgo(m.createdAt))),
|
||||
h('div', { class: 'text' }, m.message),
|
||||
(m.attachments || []).length ? h('div', { class: 'files' }, m.attachments.map((a) => h('span', { class: 'file' }, icon('clip', 12), `${a.name} (${fmtBytes(a.size)})`))) : null,
|
||||
(m.comments || []).map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff?.name || ''), ' ', h('span', { class: 'muted' }, timeAgo(c.createdAt)), h('div', {}, c.message)))));
|
||||
}));
|
||||
|
||||
// ─ Antwort schreiben
|
||||
const canReply = perms.reply_ticket !== false, canNote = perms.post_note !== false;
|
||||
let mode = canReply ? 'reply' : 'note';
|
||||
const text = h('textarea', { placeholder: 'Antwort schreiben … (Strg+Enter sendet)', 'aria-label': 'Nachricht' });
|
||||
// Das Popup schließt sich beim Klick daneben: Der Entwurf bleibt deshalb erhalten, bis er gesendet wird.
|
||||
const draftKey = `draft:${number}`;
|
||||
// Der Entwurf merkt sich auch den Modus: Eine interne Notiz darf nach dem Wiederöffnen nicht als Antwort an den Kunden dastehen.
|
||||
const savedRaw = (await chrome.storage.local.get(draftKey))[draftKey];
|
||||
const saved = typeof savedRaw === 'string' ? { text: savedRaw, mode: 'note' } : savedRaw; // ältere Entwürfe ohne Modus: sicherheitshalber als Notiz
|
||||
if (saved?.text) {
|
||||
text.value = saved.text;
|
||||
if (saved.mode === 'note' && canNote) mode = 'note'; else if (saved.mode === 'reply' && canReply) mode = 'reply';
|
||||
}
|
||||
const saveDraft = () => { if (text.value) chrome.storage.local.set({ [draftKey]: { text: text.value, mode } }); else chrome.storage.local.remove(draftKey); };
|
||||
text.addEventListener('input', saveDraft);
|
||||
const afterStatus = h('select', { 'aria-label': 'Status nach dem Senden' }, [['answered', 'Beantwortet'], ['pending', 'Wartet auf Kunde'], ['resolved', 'Gelöst'], ['closed', 'Geschlossen']].map(([v, l]) => h('option', { value: v }, l)));
|
||||
const sendReason = h('select', { 'aria-label': 'Abschlussgrund beim Senden', hidden: true }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l)));
|
||||
afterStatus.addEventListener('change', () => { sendReason.hidden = !['resolved', 'closed'].includes(afterStatus.value); });
|
||||
const sig = t.replySignatures || {};
|
||||
const sigSel = (sig.own?.available || sig.department?.available)
|
||||
? h('select', { 'aria-label': 'Signatur' }, h('option', { value: '' }, 'Signatur: Standard'), sig.own?.available ? h('option', { value: 'own' }, 'Meine Signatur') : null, sig.department?.available ? h('option', { value: 'dept' }, 'Abteilungs-Signatur') : null, h('option', { value: 'none' }, 'Ohne Signatur'))
|
||||
: null;
|
||||
let canned = null;
|
||||
const cannedSel = h('select', { 'aria-label': 'Textbaustein', onfocus: async () => {
|
||||
if (canned) return;
|
||||
try { canned = (await apiCall('canned_responses.php', { query: { departmentId: t.department?.id } })).data.items || []; }
|
||||
catch { canned = []; }
|
||||
cannedSel.append(...canned.map((c, i) => h('option', { value: i }, c.title)));
|
||||
}, onchange: () => { const c = canned?.[Number(cannedSel.value)]; if (c) { text.value = text.value ? `${text.value}\n${c.body}` : c.body; text.focus(); } cannedSel.value = ''; } }, h('option', { value: '' }, 'Textbaustein einfügen …'));
|
||||
const modeSeg = h('div', { class: 'seg' });
|
||||
const send = h('button', { class: 'btn primary', type: 'button' }, icon('send', 15), 'Senden');
|
||||
const replyOpts = h('div', { class: 'stack' }, h('div', { class: 'row2' }, cannedSel, afterStatus), sigSel, sendReason);
|
||||
function drawMode() {
|
||||
modeSeg.replaceChildren(
|
||||
canReply ? h('button', { type: 'button', class: mode === 'reply' ? 'on' : '', onclick: () => { mode = 'reply'; saveDraft(); drawMode(); } }, icon('send', 14), 'Antwort') : null,
|
||||
canNote ? h('button', { type: 'button', class: mode === 'note' ? 'on' : '', onclick: () => { mode = 'note'; saveDraft(); drawMode(); } }, icon('note', 14), 'Interne Notiz') : null);
|
||||
replyOpts.hidden = mode !== 'reply';
|
||||
text.placeholder = mode === 'reply' ? 'Antwort an den Kunden … (Strg+Enter sendet)' : 'Interne Notiz, nur für Mitarbeiter … (Strg+Enter sendet)';
|
||||
}
|
||||
async function submit() {
|
||||
const message = text.value.trim();
|
||||
if (!message) return toast('Bitte eine Nachricht eingeben.', 'err');
|
||||
const body = { number, type: mode, message: plainToHtml(message) };
|
||||
if (mode === 'reply') {
|
||||
body.setStatus = afterStatus.value;
|
||||
if (sigSel?.value) body.signature = sigSel.value;
|
||||
if (['resolved', 'closed'].includes(afterStatus.value)) { if (!sendReason.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = sendReason.value; }
|
||||
}
|
||||
send.disabled = true;
|
||||
try { await apiCall('ticket_message.php', { method: 'POST', body }); await chrome.storage.local.remove(draftKey); toast(mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); await reload(); }
|
||||
catch (e) { toast(errText(e), 'err'); send.disabled = false; }
|
||||
}
|
||||
send.addEventListener('click', submit);
|
||||
text.addEventListener('keydown', (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') { ev.preventDefault(); submit(); } });
|
||||
drawMode();
|
||||
const composer = (canReply || canNote)
|
||||
? h('div', { class: 'composer' }, modeSeg, text, replyOpts, h('div', { class: 'actions', style: { justifyContent: 'flex-end' } }, send))
|
||||
: h('div', { class: 'msg warn' }, 'Für dieses Ticket fehlt das Recht zum Antworten.');
|
||||
|
||||
clear(host).append(h('div', { class: 'stack' }, ...notices, head, actions, thread, composer, props));
|
||||
thread.lastElementChild?.scrollIntoView({ block: 'start' }); // wie im Chat: die neueste Nachricht zuerst
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Ticketliste mit Filtern, Suche und „Mehr laden“.
|
||||
import { h, clear, icon } from '../lib/dom.js';
|
||||
import { apiCall } from '../lib/core.js';
|
||||
import { spinner, ticketCard, showError } from '../lib/ui.js';
|
||||
|
||||
const OPEN = 'open,pending,answered,on_hold';
|
||||
export const FILTERS = [
|
||||
['mine', 'Meine', { assigned: 'me', status: OPEN }],
|
||||
['unassigned', 'Nicht zugewiesen', { assigned: 'unassigned', status: OPEN }],
|
||||
['overdue', 'Überfällig', { sla: 'overdue', status: OPEN }],
|
||||
['open_status', 'Unbeantwortet', { status: 'open' }],
|
||||
['following', 'Beobachtet', { following: 1, status: OPEN }],
|
||||
['snoozed', 'Wiedervorlage', { snoozed: 1, status: OPEN }],
|
||||
['all', 'Alle offenen', { status: OPEN }],
|
||||
['done', 'Gelöst/Geschlossen', { status: 'resolved,closed' }],
|
||||
];
|
||||
|
||||
const memory = { filter: 'mine', q: '' };
|
||||
|
||||
export async function renderTickets(host, ctx, params = {}) {
|
||||
if (params.filter) { memory.filter = params.filter; memory.q = ''; }
|
||||
if (params.q !== undefined) memory.q = params.q;
|
||||
let page = 1;
|
||||
const results = h('div');
|
||||
const input = h('input', { type: 'search', placeholder: 'Betreff, Nummer, Name, Text …', 'aria-label': 'Suche', maxlength: '100', value: memory.q });
|
||||
const chips = h('div', { class: 'chips', role: 'group' });
|
||||
|
||||
const drawChips = () => chips.replaceChildren(...FILTERS.map(([key, label]) => h('button', { class: `chip${!memory.q && memory.filter === key ? ' on' : ''}`, type: 'button', onclick: () => { memory.filter = key; memory.q = ''; input.value = ''; drawChips(); load(true); } }, label)));
|
||||
|
||||
async function load(reset) {
|
||||
if (reset) { page = 1; clear(results).append(spinner()); }
|
||||
const base = FILTERS.find((f) => f[0] === memory.filter)?.[2] || {};
|
||||
const query = memory.q ? { q: memory.q, status: 'open,pending,answered,on_hold,resolved,closed' } : base;
|
||||
try {
|
||||
const { data } = await apiCall('tickets.php', { query: { ...query, page, perPage: 25 } });
|
||||
if (reset) clear(results);
|
||||
else results.querySelector('.more')?.remove();
|
||||
if (reset) results.append(h('div', { class: 'muted', style: { margin: '2px 2px 8px' } }, `${data.total} Ticket${data.total === 1 ? '' : 's'}`));
|
||||
const list = results.querySelector('ul.tickets') || results.appendChild(h('ul', { class: 'tickets' }));
|
||||
if (!data.items.length && reset) { list.remove(); results.append(h('div', { class: 'empty' }, memory.q ? 'Keine Treffer.' : 'Keine Tickets in dieser Ansicht.')); }
|
||||
for (const t of data.items) list.append(ticketCard(t, (x) => ctx.go('ticket', { number: x.number })));
|
||||
if (data.hasMore) results.append(h('div', { class: 'more actions', style: { justifyContent: 'center', margin: '10px 0' } }, h('button', { class: 'btn', onclick: () => { page += 1; load(false); } }, 'Mehr laden')));
|
||||
} catch (e) { showError(ctx, results, e); }
|
||||
}
|
||||
|
||||
host.append(h('div', { class: 'stack' },
|
||||
h('form', { onsubmit: (ev) => { ev.preventDefault(); const q = input.value.trim(); memory.q = q.length >= 2 ? q : ''; drawChips(); load(true); } },
|
||||
h('div', { class: 'searchbox' }, icon('search', 16), input)),
|
||||
chips, results));
|
||||
drawChips();
|
||||
chips.querySelector('.chip.on')?.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||
load(true);
|
||||
}
|
||||
Reference in New Issue
Block a user