diff --git a/desktop/package.json b/desktop/package.json index 513574ab..6a2ab3b8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/admin-extra.js b/desktop/src/admin-extra.js index 24895b50..4a90359c 100644 --- a/desktop/src/admin-extra.js +++ b/desktop/src/admin-extra.js @@ -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 }, diff --git a/desktop/src/app.js b/desktop/src/app.js index 9f29579e..45a3935a 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -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); diff --git a/desktop/src/main.js b/desktop/src/main.js index 2631b571..bfa49126 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -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) }); diff --git a/desktop/src/preload.js b/desktop/src/preload.js index 5014d8b4..2f138d06 100644 --- a/desktop/src/preload.js +++ b/desktop/src/preload.js @@ -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), diff --git a/desktop/src/styles.css b/desktop/src/styles.css index d402727b..7b812f27 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -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); } diff --git a/desktop/test/mock.html b/desktop/test/mock.html index 30beee8f..eb175380 100644 --- a/desktop/test/mock.html +++ b/desktop/test/mock.html @@ -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: '
Viele Grüße
Technik-Team Support
LinkViele Grüße
Technik-Team Support
Link