Upload via GUI (69 Dateien)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deskhub",
|
||||
"productName": "DeskHub",
|
||||
"version": "2.4.1",
|
||||
"version": "2.7.0",
|
||||
"description": "DeskHub Desktop-Client für Mitarbeiter",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
|
||||
@@ -174,7 +174,91 @@ async function adminTicketRules(body, addBtn) {
|
||||
load();
|
||||
}
|
||||
|
||||
// ─── Digitaler Assistent (Einstellungen, zusätzliche Antworten, Fragen-Log, Gleichbedeutende Wörter, Ausprobieren) ───
|
||||
async function adminAssistant(body) {
|
||||
const A = 'assistant.php';
|
||||
const tabs = [['settings', 'Einstellungen'], ['answers', 'Zusätzliche Antworten'], ['log', 'Fragen-Log'], ['synonyms', 'Gleichbedeutende Wörter'], ['test', 'Frage ausprobieren']];
|
||||
let tab = 'settings';
|
||||
const logF = { result: '', period: '30', q: '', page: 1 };
|
||||
const bar = h('div', { class: 'tabs wrap' }), view = h('div');
|
||||
body.append(bar, view);
|
||||
const paintBar = () => clear(bar).append(...tabs.map(([k, l]) => h('button', { class: 'tab' + (tab === k ? ' active' : ''), onclick: () => { tab = k; paintBar(); paint(); } }, l)));
|
||||
const paint = () => ({ settings: pSettings, answers: pAnswers, log: pLog, synonyms: pSynonyms, test: pTest })[tab]();
|
||||
|
||||
async function pSettings() {
|
||||
const o = await run(() => api(A, { query: { section: 'overview' } })); if (!o) return;
|
||||
const s = o.settings, d = o.defaults;
|
||||
const en = h('input', { type: 'checkbox', checked: s.enabled }), mode = h('select', {}, ...opt([['offline_only', 'Nur wenn der Live-Chat ausgeschaltet oder niemand erreichbar ist (Standard)'], ['always', 'Immer (zum Testen)']], s.mode));
|
||||
const name = h('input', { value: s.name, maxlength: 60, placeholder: d.name }), greet = h('textarea', { rows: 3, maxlength: 500, placeholder: d.greeting }, s.greeting), none = h('textarea', { rows: 2, maxlength: 500, placeholder: d.noAnswer }, s.noAnswer);
|
||||
const dept = h('select', {}, ...opt(named(o.departments), s.departmentId || '', '— Standardabteilung (bzw. Abteilung des gewählten Themas) —'));
|
||||
const prio = h('select', {}, ...opt(named(o.priorities), s.priorityId || '', '— Standard —'));
|
||||
const days = h('input', { type: 'number', min: 0, max: 3650, value: s.logDays, style: 'max-width:140px' });
|
||||
const st = o.state;
|
||||
clear(view).append(h('div', { class: 'panel' },
|
||||
!o.dbReady ? h('div', { class: 'warnbox' }, 'Die Datenbank ist noch nicht aktuell. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.') : null,
|
||||
h('p', { class: 'muted' }, 'Der Assistent beantwortet Fragen von Kunden aus der Wissensdatenbank und aus den zusätzlichen Antworten. Findet er nichts, bietet er ein Ticket an. Es wird kein externer KI-Dienst verwendet.'),
|
||||
h('p', {}, 'Zustand jetzt: ', h('b', {}, !s.enabled ? 'ausgeschaltet' : st.activeNow ? 'aktiv (Kunden sehen den Assistenten)' : 'bereit, aber gerade nicht sichtbar'),
|
||||
h('span', { class: 'muted' }, ` · Live-Chat ${st.liveChatEnabled ? 'an' : 'aus'} · Wissensdatenbank im Portal ${st.kbEnabled ? 'an' : 'aus'}`)),
|
||||
h('label', { class: 'check' }, en, 'Digitalen Assistenten einschalten'),
|
||||
h('label', {}, 'Wann ist er aktiv?'), mode, h('label', {}, 'Name des Assistenten'), name, h('label', {}, 'Begrüßung'), greet, h('label', {}, 'Text, wenn er keine Antwort findet'), none,
|
||||
h('label', {}, 'Abteilung für Tickets des Assistenten'), dept, h('label', {}, 'Priorität für Tickets des Assistenten'), prio,
|
||||
h('label', {}, 'Fragen-Log: Aufbewahrung in Tagen (0 = nichts speichern)'), days,
|
||||
h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run(() => post(A, { action: 'save_settings', enabled: en.checked, mode: mode.value, name: name.value, greeting: greet.value, noAnswer: none.value, departmentId: numOrNull(dept.value) || 0, priorityId: numOrNull(prio.value) || 0, logDays: +days.value || 0 }), 'Gespeichert').then(pSettings) }, 'Speichern'))));
|
||||
}
|
||||
|
||||
async function pAnswers() {
|
||||
const r = await run(() => api(A, { query: { section: 'answers' } })); if (!r) return;
|
||||
const add = h('button', { class: 'btn', onclick: () => edit(null) }, '+ Antwort hinzufügen');
|
||||
clear(view).append(h('div', { class: 'row' }, h('span', { class: 'grow muted' }, 'Diese Antworten haben Vorrang vor der Wissensdatenbank. Der Assistent findet sie über die Wörter in der Frage und in den Stichwörtern.'), add),
|
||||
table(['Frage / Thema', 'Stichwörter', 'Genutzt', 'Status', ''], r.items.map((a) => h('tr', { class: 'click', onclick: () => edit(a) }, h('td', {}, a.title, h('div', { class: 'muted' }, a.answer.slice(0, 90))), h('td', { class: 'muted' }, a.keywords), h('td', {}, a.hits + '×'), h('td', {}, a.isActive ? 'Aktiv' : 'Aus'),
|
||||
h('td', {}, delBtn(() => run(async () => { await post(A, { action: 'delete_answer', id: a.id }); pAnswers(); }, 'Gelöscht')))))));
|
||||
}
|
||||
function edit(a, prefill) {
|
||||
const title = h('input', { value: a?.title ?? prefill ?? '', maxlength: 255, placeholder: 'z. B. Wie lange dauert der Versand?' }), kw = h('input', { value: a?.keywords ?? '', maxlength: 1000, placeholder: 'z. B. Lieferzeit, Paket, Lieferung' });
|
||||
const ans = h('textarea', { rows: 6, maxlength: 4000, placeholder: 'Reiner Text. Adressen mit https:// werden anklickbar.' }, a?.answer ?? ''), act = h('input', { type: 'checkbox', checked: a ? a.isActive : true });
|
||||
modal(a ? 'Antwort bearbeiten' : 'Neue Antwort', h('div', {}, h('label', {}, 'Frage / Thema *'), title, h('label', {}, 'Stichwörter (optional, durch Komma getrennt)'), kw, h('label', {}, 'Antwort *'), ans, h('label', { class: 'check' }, act, 'Aktiv')),
|
||||
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post(A, { action: 'save_answer', id: a?.id ?? 0, title: title.value, keywords: kw.value, answer: ans.value, isActive: act.checked }), 'Gespeichert')) { close(); tab = 'answers'; paintBar(); pAnswers(); } } }]);
|
||||
}
|
||||
|
||||
async function pLog() {
|
||||
const r = await run(() => api(A, { query: { section: 'log', result: logF.result, period: logF.period, q: logF.q, page: logF.page } })); if (!r) return;
|
||||
const reset = () => { logF.page = 1; pLog(); };
|
||||
const tiles = h('div', { class: 'cards' }, ...Object.entries(r.filters).map(([k, label]) => h('div', { class: 'stat link' + (logF.result === k ? ' active' : ''), onclick: () => { logF.result = logF.result === k ? '' : k; reset(); } }, h('div', { class: 'n' }, r.stats[k] ?? 0), h('div', { class: 'muted' }, label))));
|
||||
const sel = h('select', { onchange: (e) => { logF.result = e.target.value; reset(); } }, ...opt(Object.entries(r.filters), logF.result, 'Alle Ergebnisse'));
|
||||
const per = h('select', { onchange: (e) => { logF.period = e.target.value; reset(); } }, ...opt([['today', 'Heute'], ['7', 'Letzte 7 Tage'], ['30', 'Letzte 30 Tage'], ['all', 'Gesamter Zeitraum']], logF.period));
|
||||
const q = h('input', { value: logF.q, placeholder: 'Suchen…', onkeydown: (e) => { if (e.key === 'Enter') { logF.q = e.target.value; reset(); } } });
|
||||
const clearBtn = h('button', { class: 'btn ghost sm', onclick: () => confirm('Das gesamte Fragen-Log löschen?') && run(async () => { await post(A, { action: 'clear_log' }); reset(); }, 'Log geleert') }, 'Log leeren');
|
||||
clear(view).append(h('p', { class: 'muted' }, 'Zeigt, was Kunden den Assistenten gefragt haben und ob er eine Antwort fand. Gespeichert werden nur Frage und Ergebnis (kein Name, keine E-Mail-Adresse).'), tiles,
|
||||
h('div', { class: 'row' }, sel, per, q, h('button', { class: 'btn secondary', onclick: () => { logF.q = q.value; reset(); } }, 'Filtern'), h('span', { class: 'grow' }), clearBtn),
|
||||
r.top.length ? h('div', { class: 'panel' }, h('b', {}, 'Häufige Fragen ohne Antwort'), ...r.top.map((t) => h('div', { class: 'row' }, h('span', { class: 'grow' }, `${t.question} (${t.n}×)`), h('button', { class: 'btn ghost sm', onclick: () => { tab = 'answers'; paintBar(); edit(null, t.question); } }, 'Antwort dazu anlegen')))) : null,
|
||||
table(['Zeit', 'Frage', 'Ergebnis', 'Antwort / Ticket', ''], r.items.map((x) => h('tr', {}, h('td', { class: 'muted' }, fmtDate(x.createdAt)), h('td', {}, x.question), h('td', {}, (r.filters[x.result] || x.result) + (x.helpful === null ? '' : x.helpful ? ' 👍' : ' 👎')), h('td', { class: 'muted' }, x.ticketNumber ? '#' + x.ticketNumber : (x.matchedTitle || '')),
|
||||
h('td', {}, x.result === 'none' ? h('button', { class: 'btn ghost sm', onclick: () => { tab = 'answers'; paintBar(); edit(null, x.question); } }, 'Antwort anlegen') : null)))),
|
||||
h('div', { class: 'row pager' }, h('button', { class: 'btn ghost sm', disabled: r.page <= 1, onclick: () => { logF.page--; pLog(); } }, '‹'), h('span', { class: 'muted' }, `${r.total} Einträge · Seite ${r.page} von ${r.pages}`), h('button', { class: 'btn ghost sm', disabled: r.page >= r.pages, onclick: () => { logF.page++; pLog(); } }, '›')));
|
||||
}
|
||||
|
||||
async function pSynonyms() {
|
||||
const o = await run(() => api(A, { query: { section: 'overview' } })); if (!o) return;
|
||||
const t = h('textarea', { rows: 8, maxlength: 5000, placeholder: 'Zugang, Einladung, beitreten\nGeld zurück, Erstattung, Rückzahlung' }, o.settings.synonyms);
|
||||
clear(view).append(h('div', { class: 'panel' }, h('p', { class: 'muted' }, 'Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel „Zugang, Einladung, beitreten“. Einige Gruppen sind eingebaut, deine ergänzen oder überschreiben sie.'), t,
|
||||
h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run(async () => { const r = await post(A, { action: 'save_synonyms', text: t.value }); toast(`Gespeichert (${r.groups} Gruppen)`); }) }, 'Speichern'))));
|
||||
}
|
||||
|
||||
function pTest() {
|
||||
const q = h('input', { placeholder: 'z. B. Wie setze ich mein Passwort zurück?', maxlength: 300 }), out = h('div');
|
||||
const go = () => run(async () => {
|
||||
const r = await post(A, { action: 'test', question: q.value });
|
||||
clear(out).append(r.found ? h('div', { class: 'panel' }, h('div', { class: 'muted' }, (r.result.source === 'custom' ? 'Zusätzliche Antwort' : 'Wissensdatenbank') + ': ' + r.result.title), h('p', { style: 'white-space:pre-wrap' }, r.result.text), ...r.result.others.map((x) => h('div', { class: 'muted' }, 'Weiterer Treffer: ' + x)))
|
||||
: h('div', { class: 'muted' }, 'Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür eine zusätzliche Antwort an oder ergänze die Wissensdatenbank.'));
|
||||
});
|
||||
q.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
|
||||
clear(view).append(h('div', { class: 'panel' }, h('p', { class: 'muted' }, 'Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).'), h('div', { class: 'row' }, q, h('button', { class: 'btn', onclick: go }, 'Testen')), out));
|
||||
}
|
||||
|
||||
paintBar(); paint();
|
||||
}
|
||||
|
||||
ADMIN.push(
|
||||
{ key: 'assistant', label: 'Digitaler Assistent', perm: 'manage_assistant', custom: adminAssistant },
|
||||
{ key: 'teams', label: 'Teams', perm: 'manage_teams', custom: adminTeams, add: true },
|
||||
{ key: 'roles', label: 'Rollen', perm: 'manage_roles', custom: adminRoles, add: true },
|
||||
{ key: 'escalations', label: 'Eskalationsregeln', perm: 'manage_escalations', custom: adminEscalations, add: true },
|
||||
|
||||
+52
-5
@@ -317,6 +317,9 @@ const VIEWS = {
|
||||
unassigned: ['Nicht zugewiesen', { status: 'open,pending,answered,on_hold', assigned: 'unassigned', sla: '' }],
|
||||
overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }],
|
||||
atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }],
|
||||
paused: ['Pausiert', { status: 'on_hold', assigned: '', sla: '' }],
|
||||
snoozed: ['💤 Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
|
||||
following: ['👁 Beobachtet', { status: 'open,pending,answered,on_hold', assigned: '', sla: '', following: '1' }],
|
||||
closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }],
|
||||
all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }],
|
||||
};
|
||||
@@ -399,6 +402,32 @@ async function vTickets(main, preset) {
|
||||
}
|
||||
|
||||
// ─── Ticket-Detail ───────────────────────────────────────────────────────────
|
||||
// Seite der Weboberfläche im Browser öffnen (einmalige Anmeldung; der Server prüft die Rechte)
|
||||
async function openWebPage(path) {
|
||||
const r = await window.deskhub.openWeb(path);
|
||||
if (r?.error) toast(r.error.message || 'Die Seite konnte nicht geöffnet werden.', true);
|
||||
}
|
||||
|
||||
// Wiedervorlage: Zeitpunkt wählen (Voreinstellungen oder eigener Zeitpunkt), Rückgabe als ISO-Zeit (UTC) für den Server
|
||||
function snoozeChoices() {
|
||||
const now = Date.now();
|
||||
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(8, 0, 0, 0);
|
||||
return [['1h', 'In 1 Stunde', now + 3600e3], ['3h', 'In 3 Stunden', now + 3 * 3600e3], ['tomorrow', 'Morgen früh (8:00 Uhr)', tomorrow.getTime()], ['3d', 'In 3 Tagen', now + 3 * 86400e3], ['1w', 'In 1 Woche', now + 7 * 86400e3]];
|
||||
}
|
||||
function askSnooze(onPick) {
|
||||
const choices = snoozeChoices();
|
||||
const sel = h('select', {}, ...choices.map(([k, l]) => h('option', { value: k }, l)), h('option', { value: 'custom' }, 'Zu einem Zeitpunkt …'));
|
||||
const when = h('input', { type: 'datetime-local', class: 'hidden' });
|
||||
sel.addEventListener('change', () => when.classList.toggle('hidden', sel.value !== 'custom'));
|
||||
modal('Auf später legen', h('div', {}, h('p', { class: 'muted' }, 'Das Ticket verschwindet aus den Listen und kommt zum gewählten Zeitpunkt zurück, mit Benachrichtigung. Antwortet der Kunde früher, kommt es sofort zurück.'), h('label', {}, 'Wann?'), sel, when),
|
||||
[{ label: 'Auf später legen', fn: async (close) => {
|
||||
let ts;
|
||||
if (sel.value === 'custom') ts = when.value ? new Date(when.value).getTime() : NaN; else ts = choices.find(([k]) => k === sel.value)[2];
|
||||
if (!(ts > Date.now() + 30e3)) { toast('Bitte einen Zeitpunkt in der Zukunft wählen.', true); return; }
|
||||
close(); onPick(new Date(ts).toISOString());
|
||||
} }]);
|
||||
}
|
||||
|
||||
async function vTicket(main, number) {
|
||||
let t;
|
||||
try { t = (await api('ticket.php', { query: { number } })).ticket; }
|
||||
@@ -509,6 +538,11 @@ async function vTicket(main, number) {
|
||||
!closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null,
|
||||
!closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null,
|
||||
closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null,
|
||||
t.snoozedUntil
|
||||
? h('button', { class: 'btn ghost sm', onclick: () => update({ snoozeUntil: null }, 'Wiedervorlage aufgehoben') }, 'Wiedervorlage aufheben')
|
||||
: h('button', { class: 'btn ghost sm', title: 'Ticket auf später legen', onclick: () => askSnooze((iso) => update({ snoozeUntil: iso }, 'Wiedervorlage gesetzt')) }, '💤 Auf später'),
|
||||
h('button', { class: 'btn ghost sm', onclick: () => update({ following: !t.following }, t.following ? 'Du beobachtest das Ticket nicht mehr' : 'Du beobachtest das Ticket jetzt') }, t.following ? 'Nicht mehr beobachten' : '👁 Beobachten'),
|
||||
h('button', { class: 'btn ghost sm', title: 'Im Browser öffnen: zusammenführen, aufteilen, drucken', onclick: () => openWebPage('/staff/ticket_view.php?n=' + encodeURIComponent(t.number)) }, '🌐 Im Browser'),
|
||||
spamBtn,
|
||||
h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'),
|
||||
h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren'));
|
||||
@@ -518,7 +552,8 @@ async function vTicket(main, number) {
|
||||
main.append(
|
||||
h('div', { class: 'tkhead' },
|
||||
h('div', { class: 'tkhead-top' }, h('button', { class: 'btn ghost sm', onclick: () => go('tickets') }, 'Zurück zu Tickets'), h('span', { class: 'chip' }, '#' + t.number),
|
||||
h('span', { class: 'pill', color: st.color }, st.label), t.isOverdue ? h('span', { class: 'pill', color: '#d63b3b' }, 'Überfällig') : null, t.isSpam ? h('span', { class: 'pill', color: '#667384' }, 'Spam') : null),
|
||||
h('span', { class: 'pill', color: st.color }, st.label), t.isOverdue ? h('span', { class: 'pill', color: '#d63b3b' }, 'Überfällig') : null, t.isSpam ? h('span', { class: 'pill', color: '#667384' }, 'Spam') : null,
|
||||
t.snoozedUntil ? h('span', { class: 'pill', color: '#6b5bd6' }, '💤 bis ' + fmtDate(t.snoozedUntil)) : null),
|
||||
h('h2', {}, t.subject),
|
||||
h('div', { class: 'sub' }, `${t.requesterName} · ${t.department.name} · erstellt ${fmtDate(t.createdAt)}`),
|
||||
actions),
|
||||
@@ -541,6 +576,9 @@ async function vTicket(main, number) {
|
||||
h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kunde'),
|
||||
h('div', { class: 'custcard' }, h('span', { class: 'avatar' }, initials(t.requesterName)), h('div', {}, h('b', {}, t.requesterName), h('div', { class: 'muted' }, t.requesterEmail))),
|
||||
t.requesterEmail ? h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => go('customers', t.requesterEmail) }, 'Kundenprofil')) : null),
|
||||
(t.similar && (t.similar.articles?.length || t.similar.tickets?.length)) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Dazu passend'),
|
||||
...(t.similar.articles || []).map((a) => h('div', { class: 'mini' }, h('span', {}, '📘 ' + a.title), ' ', h('button', { class: 'btn ghost sm', onclick: () => { navigator.clipboard?.writeText((state.serverUrl || '') + a.url); toast('Link kopiert'); } }, 'Link kopieren'))),
|
||||
...(t.similar.tickets || []).map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, x.number + ' · ' + x.subject))) : null,
|
||||
h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Weitere Tickets des Kunden'), others))));
|
||||
|
||||
// Neue Nachrichten von Kunden/Kollegen erkennen, ohne den Entwurf zu verlieren
|
||||
@@ -753,7 +791,7 @@ const ADMIN = [
|
||||
];
|
||||
|
||||
const ADMIN_GROUPS = [
|
||||
['Verwalten', ['departments', 'priorities', 'sla_plans', 'help_topics', 'kb', 'email_addresses', 'email_templates', 'ticket_rules', 'banlist']],
|
||||
['Verwalten', ['departments', 'priorities', 'sla_plans', 'help_topics', 'kb', 'email_addresses', 'email_templates', 'ticket_rules', 'banlist', 'assistant']],
|
||||
['Kunden', ['organizations']],
|
||||
['Agenten', ['staff', 'teams', 'roles', 'status']],
|
||||
['System', ['audit', 'time', 'escalations', 'business_hours']],
|
||||
@@ -761,7 +799,7 @@ const ADMIN_GROUPS = [
|
||||
const ADMIN_DESC = {
|
||||
departments: 'Status, Leiter, Mitglieder, E-Mail', priorities: 'Reaktions- und Lösungszeiten', sla_plans: 'Benannte Zeiten je Abteilung oder Thema', help_topics: 'Thema, Typ, Abteilung, Priorität',
|
||||
kb: 'Artikel und Kategorien', email_addresses: 'Adressen und Postfach-Abruf', email_templates: 'Texte der automatischen E-Mails', ticket_rules: 'Automatisierung und Auto-Antworten', banlist: 'Absender oder Mailanbieter sperren',
|
||||
organizations: 'Kunden zu Firmen bündeln',
|
||||
organizations: 'Kunden zu Firmen bündeln', assistant: 'Antworten, Fragen-Log, Einstellungen, Synonyme',
|
||||
staff: 'Konten, Rollen, Abteilungszugriff', teams: 'Mitarbeiter-Gruppen', roles: 'Individuelle Berechtigungen', status: 'Status ändern, z. B. auf Offline setzen',
|
||||
audit: 'Änderungen und fehlgeschlagene Mails', time: 'Erfasste Arbeitszeiten', escalations: 'Regeln für überfällige Tickets', business_hours: 'Öffnungszeiten und Feiertage',
|
||||
};
|
||||
@@ -779,8 +817,17 @@ async function vAdmin(main, sectionKey) {
|
||||
return tiles.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, title), h('div', { class: 'tiles' }, ...tiles)) : null;
|
||||
});
|
||||
const rest = [...entries.keys()].filter((k) => !used.has(k)).map((k) => h('div', { class: 'tile', onclick: entries.get(k).open }, h('div', { class: 'tt' }, entries.get(k).label)));
|
||||
main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Verwaltung'), h('div', { class: 'sub' }, 'Nur Bereiche, für die du freigeschaltet bist. Backup, Update, Postfächer und Einstellungen gibt es nur in der Weboberfläche.'))),
|
||||
...groupsView, rest.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, 'Weitere'), h('div', { class: 'tiles' }, ...rest)) : null);
|
||||
const webBox = h('div');
|
||||
main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Verwaltung'), h('div', { class: 'sub' }, 'Nur Bereiche, für die du freigeschaltet bist. Weitere Seiten (Einstellungen, Assistent, Backup …) öffnen sich im Browser, du bist dort automatisch angemeldet.'))),
|
||||
...groupsView, webBox, rest.length ? h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, 'Weitere'), h('div', { class: 'tiles' }, ...rest)) : null);
|
||||
// Seiten der Weboberfläche: Der Server liefert nur, was dieser Mitarbeiter öffnen darf
|
||||
api('web_pages.php').then((r) => {
|
||||
const groups = new Map();
|
||||
for (const p of (r.pages || []).filter((x) => x.key !== 'assistant')) { if (!groups.has(p.group)) groups.set(p.group, []); groups.get(p.group).push(p); }
|
||||
webBox.append(...[...groups].map(([title, pages]) => h('section', { class: 'tilegroup' }, h('h3', { class: 'grouplabel' }, title + ' (im Browser)'),
|
||||
h('div', { class: 'tiles' }, ...pages.map((p) => h('div', { class: 'tile', tabindex: '0', onclick: () => openWebPage(p.path), onkeydown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openWebPage(p.path); } } },
|
||||
h('div', { class: 'tt' }, p.label + ' ↗'), h('div', { class: 'td' }, 'Öffnet die Weboberfläche')))))));
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const s = sections.find((x) => x.key === sectionKey);
|
||||
|
||||
@@ -88,6 +88,19 @@ ipcMain.handle('api', async (_e, req) => {
|
||||
} catch (e) { return { error: { status: e.status, code: e.code, message: e.message || String(e) } }; }
|
||||
});
|
||||
|
||||
// Seite der Weboberfläche im Standardbrowser öffnen: Der Server gibt eine einmalige Anmelde-Adresse (und prüft dabei die Rechte)
|
||||
ipcMain.handle('web:open', async (_e, { path: p }) => {
|
||||
if (typeof p !== 'string' || !p.startsWith('/staff/') || p.length > 200) return { error: { message: 'Ungültige Seite.' } };
|
||||
try {
|
||||
const r = await callApi({ path: 'web_pages.php', method: 'POST', body: { path: p } });
|
||||
const u = new URL(String(r.data?.url || ''));
|
||||
const base = new URL(session.serverUrl);
|
||||
if (u.protocol !== 'https:' || u.origin !== base.origin || !u.pathname.endsWith('/staff/app_login.php')) return { error: { message: 'Der Server hat eine unerwartete Adresse geliefert.' } };
|
||||
await shell.openExternal(u.href);
|
||||
return { data: true };
|
||||
} catch (e) { return { error: { message: e.message || String(e) } }; }
|
||||
});
|
||||
|
||||
ipcMain.handle('notify', (_e, { title, body }) => {
|
||||
if (Notification.isSupported()) {
|
||||
const n = new Notification({ title: String(title).slice(0, 100), body: String(body).slice(0, 200) });
|
||||
|
||||
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('deskhub', {
|
||||
login: (data) => ipcRenderer.invoke('auth:login', data),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
api: (req) => ipcRenderer.invoke('api', req),
|
||||
openWeb: (path) => ipcRenderer.invoke('web:open', { path }),
|
||||
notify: (title, body) => ipcRenderer.invoke('notify', { title, body }),
|
||||
settings: () => ipcRenderer.invoke('settings:get'),
|
||||
setSettings: (patch) => ipcRenderer.invoke('settings:set', patch),
|
||||
|
||||
@@ -107,6 +107,7 @@ h2 { margin: 0 0 18px; font-size: 24px; font-weight: 700; letter-spacing: -.015e
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; margin-bottom: 10px; }
|
||||
.stat { background: var(--panel); border-radius: var(--radius); padding: 16px 18px; border: 1px solid var(--line); box-shadow: var(--shadow-sm); position: relative; overflow: hidden; transition: transform .12s, box-shadow .12s, border-color .12s; }
|
||||
.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--accent); }
|
||||
.stat.active { border-color: var(--accent); }
|
||||
.stat.link { cursor: pointer; } .stat.link:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--accent); }
|
||||
.stat .n { font-size: 32px; font-weight: 700; line-height: 1.15; letter-spacing: -.02em; }
|
||||
.stat.bad::before { background: var(--bad); } .stat.bad .n { color: var(--bad); }
|
||||
|
||||
+11
-2
@@ -13,8 +13,16 @@ const routes = {
|
||||
'me.php': () => ({ staff }), 'metadata.php': () => ({ staff, departments: [{ id: 1, name: 'Technik' }], priorities: prio, agents: [{ id: 1, name: 'Max Muster' }, { id: 2, name: 'Eva Beispiel' }], statuses, tags: [], liveChatEnabled: true }),
|
||||
'activity.php': () => ({ newReplies: 0, newUnassigned: 0, serverTime: '2026-10-12 10:00:00', health: window.__health || [] }),
|
||||
'dashboard.php': () => ({ myOpen: 3, overdue: 2, unassigned: 1, unanswered: 4, resolvedToday: 5, slaPercent: 92.4, recent: tickets, departments: [{ name: 'Technik', open: 8, overdue: 2 }, { name: 'Vertrieb', open: 3, overdue: 0 }] }),
|
||||
'web_pages.php': () => ({ pages: [{ key: 'settings', label: 'Einstellungen', path: '/staff/settings.php', group: 'Verwaltung' }, { key: 'backup', label: 'Backup', path: '/staff/backup.php', group: 'System' }] }),
|
||||
'assistant.php': (req) => {
|
||||
if (req.method === 'POST') { (window.__assistantPosts = window.__assistantPosts || []).push(req.body); return req.body.action === 'test' ? { found: true, result: { source: 'custom', title: 'Versand', text: 'Zwei bis drei Werktage.', others: [] } } : req.body.action === 'save_synonyms' ? { ok: true, groups: 2 } : { ok: true, item: { id: 9 } }; }
|
||||
const sec = (req.query || {}).section;
|
||||
if (sec === 'answers') return { items: [{ id: 1, title: 'Wie lange dauert der Versand?', keywords: 'Lieferzeit', answer: 'Zwei bis drei Werktage.', isActive: true, hits: 3, updatedAt: '2026-10-20 10:00:00' }] };
|
||||
if (sec === 'log') return { filters: { custom: 'Meine Antwort gefunden', kb: 'Wissensdatenbank', none: 'Keine Antwort', helpful: 'Hat geholfen' }, stats: { custom: 4, kb: 2, none: 1, helpful: 3 }, top: [{ question: 'Wie kündige ich?', n: 2, last: '2026-10-22 09:00:00' }], total: 1, pages: 1, page: 1, items: [{ id: 1, question: 'Wie kündige ich?', result: 'none', matchedTitle: null, ticketNumber: null, helpful: null, createdAt: '2026-10-22 09:00:00' }] };
|
||||
return { dbReady: true, settings: { enabled: true, mode: 'offline_only', name: '', greeting: '', noAnswer: '', departmentId: 0, priorityId: 0, logDays: 90, synonyms: 'Zugang, Einladung, beitreten' }, defaults: { name: 'Digitaler Assistent', greeting: 'Hallo!', noAnswer: 'Keine Antwort.' }, state: { activeNow: true, liveChatEnabled: false, kbEnabled: true, helpdeskOffline: false }, departments: [{ id: 1, name: 'Technik' }], priorities: [{ id: 1, name: 'Normal' }] };
|
||||
},
|
||||
'tickets.php': () => ({ items: tickets, page: 1, perPage: 30, total: 4, hasMore: false }),
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', similar: { articles: [{ title: 'Drucker einrichten', url: '/kb_article.php?slug=drucker' }], tickets: [{ number: 'T-77', subject: 'Alter Druckerfall' }] }, lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
{ id: 1, authorType: 'user', authorName: 'Kunde 1', message: 'Mein Drucker druckt nichts mehr.\nBitte helft.', isInternalNote: false, createdAt: '2026-10-12 08:00:00', attachments: [{ id: 1, name: 'foto.png', size: 20480 }], comments: [] },
|
||||
{ id: 2, authorType: 'staff', authorName: 'Max Muster', message: 'Wir schauen uns das an.', isInternalNote: false, createdAt: '2026-10-12 08:30:00', attachments: [], comments: [] },
|
||||
{ id: 3, authorType: 'staff', authorName: 'Max Muster', message: 'Treiber prüfen', isInternalNote: true, createdAt: '2026-10-12 08:35:00', attachments: [], comments: [] }] } }),
|
||||
@@ -39,7 +47,8 @@ window.deskhub = {
|
||||
settings: async () => ({ theme: window.__theme || 'dark', autostart: false, closeToTray: true, notifications: true, pollSeconds: 60, version: '1.1.0', packaged: false }),
|
||||
setSettings: async (p) => { if (p.theme) window.__theme = p.theme; return window.deskhub.settings(); },
|
||||
login: async () => ({ staff }), logout: async () => true, notify: async () => {}, badge: async () => {},
|
||||
api: async (req) => { window.__calls.push(req.path); if (req.body) (window.__bodies = window.__bodies || {})[req.path] = req.body; window.__bodies = window.__bodies || {}; const f = routes[req.path]; return f ? { data: f() } : { error: { message: 'Mock: ' + req.path + ' nicht vorhanden' } }; },
|
||||
openWeb: async (path) => { window.__web = path; return { data: true }; },
|
||||
api: async (req) => { window.__calls.push(req.path); if (req.body) (window.__bodies = window.__bodies || {})[req.path] = req.body; window.__bodies = window.__bodies || {}; const f = routes[req.path]; return f ? { data: f(req) } : { error: { message: 'Mock: ' + req.path + ' nicht vorhanden' } }; },
|
||||
onSessionExpired() {}, onNavigate() {},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -39,11 +39,20 @@ app.whenReady().then(async () => {
|
||||
|
||||
await run(`go('tickets')`);
|
||||
check('Ticketliste zeigt alle Tickets', await waitFor(`['Drucker druckt nicht','Passwort zurücksetzen','Rechnung fehlt','VPN Verbindung bricht ab'].every(t => document.getElementById('main').innerText.includes(t))`));
|
||||
check('Reiter „Wiedervorlage“, „Pausiert“ und „Beobachtet“ sind da', await run(`(() => { const t = [...document.querySelectorAll('#main .tabs button')].map((b) => b.innerText).join('|'); return t.includes('Wiedervorlage') && t.includes('Pausiert') && t.includes('Beobachtet'); })()`));
|
||||
check('Gespeicherte Ansicht erscheint als Chip', await run(`document.getElementById('main').innerText.includes('Meine überfälligen')`));
|
||||
|
||||
await run(`go('ticket', 1)`);
|
||||
check('Ticketdetail zeigt Betreff und Verlauf', await waitFor(`['Drucker druckt nicht', 'Mein Drucker druckt nichts mehr', 'Treiber prüfen'].every(x => document.getElementById('main').innerText.includes(x))`));
|
||||
check('Ticketdetail zeigt Eigenschaften und Aktionen', await run(`['eigenschaften', 'schließen'].every(x => document.getElementById('main').innerText.toLowerCase().includes(x))`));
|
||||
check('Ticketdetail: Wiedervorlage- und Beobachten-Knopf sind da', await run(`(() => { const t = document.getElementById('main').innerText; return t.includes('Auf später') && t.includes('Beobachten'); })()`));
|
||||
check('Ticketdetail: „Dazu passend“ zeigt Artikel und ähnliches Ticket', await run(`(() => { const t = document.getElementById('main').innerText; return t.toLowerCase().includes('dazu passend') && t.includes('Drucker einrichten') && t.includes('T-77'); })()`));
|
||||
await run(`window.__bodies = {}; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Beobachten') && !b.innerText.includes('Nicht')).click()`);
|
||||
await run(`window.__web = ''; [...document.querySelectorAll('#main button')].find((b) => b.innerText.includes('Im Browser')).click()`);
|
||||
check('Ticket: „Im Browser“ öffnet das Ticket in der Weboberfläche', await waitFor(`String(window.__web).startsWith('/staff/ticket_view.php?n=')`));
|
||||
check('Beobachten sendet following=true an den Server', await waitFor(`(window.__bodies['ticket_update.php'] || {}).following === true`));
|
||||
await run(`go('ticket', 1)`);
|
||||
await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht')`);
|
||||
check('Interne Notiz ist gekennzeichnet', await run(`!!document.querySelector('.thread.note')`));
|
||||
// Textbausteine nur für die Abteilung des Tickets, Signatur-Auswahl mit Vorschau
|
||||
await waitFor(`!!document.querySelector('.composer select')`);
|
||||
@@ -78,7 +87,36 @@ app.whenReady().then(async () => {
|
||||
check('Verwaltung „' + key + '“ rendert ohne Fehler', ok, await run(`(document.querySelector('#main .warnbox')||{}).innerText || ''`));
|
||||
}
|
||||
|
||||
// Digitaler Assistent: Reiter, Einstellungen, Antworten, Fragen-Log, Synonyme, Ausprobieren
|
||||
await run(`go('admin', 'assistant')`);
|
||||
const txt = () => run(`document.getElementById('main').innerText`);
|
||||
const tabBtn = (label) => run(`[...document.querySelectorAll('#main .tabs button')].find((b) => b.innerText.includes(${JSON.stringify(label)})).click()`);
|
||||
check('Assistent: Einstellungen zeigen Zustand und Felder', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Zustand jetzt') && t.includes('aktiv') && t.includes('Aufbewahrung'); })()`));
|
||||
await run(`window.__assistantPosts = []; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Speichern').click()`);
|
||||
check('Assistent: Speichern sendet die Einstellungen', await waitFor(`(window.__assistantPosts[0] || {}).action === 'save_settings' && window.__assistantPosts[0].logDays === 90`));
|
||||
await tabBtn('Zusätzliche Antworten');
|
||||
check('Assistent: Zusätzliche Antworten werden aufgelistet', await waitFor(`document.getElementById('main').innerText.includes('Wie lange dauert der Versand?') && document.getElementById('main').innerText.includes('3×')`));
|
||||
await tabBtn('Fragen-Log');
|
||||
check('Assistent: Fragen-Log zeigt Kennzahlen, Frage und häufige Fragen ohne Antwort', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Wie kündige ich?') && t.includes('Häufige Fragen ohne Antwort') && t.includes('Hat geholfen'); })()`));
|
||||
await tabBtn('Gleichbedeutende Wörter');
|
||||
check('Assistent: Synonyme sind sichtbar und speicherbar', await waitFor(`!!document.querySelector('#main textarea') && document.querySelector('#main textarea').value.includes('Einladung')`));
|
||||
await run(`window.__assistantPosts = []; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Speichern').click()`);
|
||||
check('Assistent: Synonyme senden save_synonyms', await waitFor(`(window.__assistantPosts[0] || {}).action === 'save_synonyms'`));
|
||||
await tabBtn('Frage ausprobieren');
|
||||
await run(`window.__assistantPosts = []; document.querySelector('#main input').value = 'Wann kommt das Paket?'; [...document.querySelectorAll('#main button')].find((b) => b.innerText === 'Testen').click()`);
|
||||
check('Assistent: Ausprobieren zeigt die Antwort', await waitFor(`document.getElementById('main').innerText.includes('Zwei bis drei Werktage.')`));
|
||||
// Rechte: ohne das Recht ist der Bereich nicht in der Verwaltung
|
||||
await run(`state.staff.role = 'agent'; state.staff.permissions = {}`);
|
||||
check('Assistent: ohne Recht nicht in den Verwaltungsbereichen', await run(`!adminSections().some((s) => s.key === 'assistant')`));
|
||||
await run(`state.staff.permissions = { manage_assistant: true }`);
|
||||
check('Assistent: mit dem Recht „manage_assistant“ ist er da, andere Bereiche nicht', await run(`adminSections().some((s) => s.key === 'assistant') && !adminSections().some((s) => s.key === 'departments')`));
|
||||
await run(`state.staff.role = 'admin'`);
|
||||
|
||||
// Abteilung: automatische Zuweisung neuer Tickets einstellen
|
||||
await run(`go('admin')`);
|
||||
check('Verwaltung zeigt die Weboberfläche-Seiten, die der Server freigibt', await waitFor(`(() => { const t = document.getElementById('main').innerText; return t.includes('Backup') && t.includes('Einstellungen') && t.includes('im Browser'); })()`));
|
||||
await run(`[...document.querySelectorAll('#main .tile')].find((x) => x.innerText.includes('Backup')).click()`);
|
||||
check('Klick öffnet die Seite über die einmalige Anmeldung (nur /staff/-Pfad)', await waitFor(`window.__web === '/staff/backup.php'`));
|
||||
await run(`go('admin', 'departments')`);
|
||||
check('Abteilungen: Liste lädt', await waitFor(`document.getElementById('main').innerText.includes('Technik')`));
|
||||
await run(`document.querySelector('#main tr.click').click()`);
|
||||
|
||||
Reference in New Issue
Block a user