diff --git a/desktop/package.json b/desktop/package.json index 1c363851..27e49ac6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,26 +1,26 @@ -{ - "name": "deskhub", - "productName": "DeskHub", - "version": "2.1.0", - "description": "DeskHub Desktop-Client für Mitarbeiter", - "main": "src/main.js", - "author": "M_Viper", - "license": "SEE LICENSE IN LICENSE", - "private": true, - "scripts": { - "start": "electron .", - "test": "electron test/run.js", - "build": "electron-builder --win dir && node scripts/build-setup.js" - }, - "build": { - "appId": "de.deskhub.desktop", - "productName": "DeskHub", - "artifactName": "DeskHub.${ext}", - "files": ["src/**/*", "package.json", "LICENSE"], - "win": { "target": "dir", "icon": "src/assets/icon.ico" } - }, - "devDependencies": { - "electron": "^33.2.0", - "electron-builder": "^25.1.8" - } -} +{ + "name": "deskhub", + "productName": "DeskHub", + "version": "2.1.3", + "description": "DeskHub Desktop-Client für Mitarbeiter", + "main": "src/main.js", + "author": "M_Viper", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "scripts": { + "start": "electron .", + "test": "electron test/run.js", + "build": "electron-builder --win dir && node scripts/build-setup.js" + }, + "build": { + "appId": "de.deskhub.desktop", + "productName": "DeskHub", + "artifactName": "DeskHub.${ext}", + "files": ["src/**/*", "package.json", "LICENSE"], + "win": { "target": "dir", "icon": "src/assets/icon.ico" } + }, + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + } +} diff --git a/desktop/src/app.js b/desktop/src/app.js index 7398fe78..400b6ffd 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -420,13 +420,51 @@ async function vTicket(main, number) { const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {}); let cannedList = []; const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…')); - api('canned_responses.php').then((r) => { cannedList = r.items; cannedList.forEach((c) => canned.append(h('option', { value: c.id }, c.title))); }).catch(() => {}); + // Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern) + api('canned_responses.php', { query: { departmentId: t.department?.id } }).then((r) => { + cannedList = r.items.filter((c) => c.departmentId == null || c.departmentId === t.department?.id); + const group = (label, items) => { if (items.length) canned.append(h('optgroup', { label }, ...items.map((c) => h('option', { value: c.id }, c.title)))); }; + group('Abteilung ' + (t.department?.name ?? ''), cannedList.filter((c) => c.departmentId != null)); + group('Für alle Abteilungen', cannedList.filter((c) => c.departmentId == null)); + }).catch(() => {}); + + // Signatur wählen (meine / Abteilung / keine) mit Vorschau; ältere Server liefern keine Signaturen: dann ohne Auswahl + const sigs = t.replySignatures; + let sigChoice = sigs?.default ?? 'none'; + const sigPreview = h('div', { class: 'sigpreview' }); + // Die Signatur kommt als bereits vom Server bereinigtes HTML; hier wird sie nochmals gesäubert (keine Skripte, Handler, Stile, Links) + const sigNodes = (html) => { + const doc = new DOMParser().parseFromString('' + html, 'text/html'); + doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,input,button,svg,math,base').forEach((n) => n.remove()); + doc.body.querySelectorAll('*').forEach((el) => { + for (const a of [...el.attributes]) { + const n = a.name.toLowerCase(); + if (n.startsWith('on') || n === 'style' || n === 'srcset' || n === 'href') el.removeAttribute(a.name); + else if (n === 'src' && !/^(https:|data:image\/)/i.test(a.value.trim())) el.removeAttribute(a.name); + } + }); + return [...doc.body.childNodes]; + }; + const paintSig = () => { + const e = sigChoice === 'own' ? sigs.own : sigChoice === 'dept' ? sigs.department : null; + clear(sigPreview); + sigPreview.classList.toggle('text', !(e?.available && e.html)); + if (e?.available && e.html) sigPreview.append(...sigNodes(e.html)); + else sigPreview.textContent = e?.available ? e.text : (sigChoice === 'none' ? 'Es wird keine Signatur angehängt.' : 'Keine Signatur hinterlegt.'); + }; + const sigSelect = sigs ? h('select', { onchange: (e) => { sigChoice = e.target.value; paintSig(); } }, + h('option', { value: 'own', selected: sigChoice === 'own', disabled: !sigs.own.available }, 'Meine Signatur' + (sigs.own.available ? '' : ' (nicht hinterlegt)')), + h('option', { value: 'dept', selected: sigChoice === 'dept', disabled: !sigs.department.available }, 'Abteilung „' + sigs.department.name + '“' + (sigs.department.available ? '' : ' (nicht hinterlegt)')), + h('option', { value: 'none', selected: sigChoice === 'none' }, 'Keine Signatur')) : null; + const sigBox = sigs ? h('div', { class: 'sigbox' }, h('div', { class: 'row' }, h('span', { class: 'muted small' }, 'Signatur'), sigSelect, h('span', { class: 'muted small' }, 'wird nur an Antworten an den Kunden angehängt')), sigPreview) : null; + if (sigs) paintSig(); const sendBtn = h('button', { class: 'btn' }, 'Senden'); const paintTabs = () => { clear(tabs); if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden')); if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz')); sendStatus.classList.toggle('hidden', mode !== 'reply'); + sigBox?.classList.toggle('hidden', mode !== 'reply'); sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern'; ta.classList.toggle('noteedit', mode === 'note'); }; @@ -435,7 +473,7 @@ async function vTicket(main, number) { if (!ta.value.trim() || sendBtn.disabled) return; if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return; sendBtn.disabled = true; - const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); + const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); sendBtn.disabled = false; if (ok) { store.del(draftKey(mode)); reload(); } }; @@ -481,7 +519,7 @@ async function vTicket(main, number) { actions), t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null, h('div', { class: 'split' }, - h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), + h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), h('div', {}, h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'), h('dl', { class: 'props' }, @@ -490,6 +528,8 @@ async function vTicket(main, number) { ...prop('Abteilung', t.department.name), ...prop('Erstellt', fmtDate(t.createdAt)), ...(t.resolutionDueAt ? prop('Lösung fällig', h('span', { class: t.isOverdue ? 'over' : '' }, fmtDate(t.resolutionDueAt))) : []))), + (t.customFields && t.customFields.length) ? h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Angaben des Kunden'), + h('dl', { class: 'props' }, ...t.customFields.flatMap((f) => prop(f.label, f.value || '–')))) : null, 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), diff --git a/desktop/src/index.html b/desktop/src/index.html index f188652d..15eae98c 100644 --- a/desktop/src/index.html +++ b/desktop/src/index.html @@ -2,7 +2,7 @@ - + DeskHub diff --git a/desktop/src/main.js b/desktop/src/main.js index 8bfa012e..2631b571 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -40,6 +40,7 @@ async function callApi({ path: p, method = 'GET', query, body, auth = true, serv if (auth) { if (!session?.token) throw { status: 401, code: 'UNAUTHENTICATED', message: 'Nicht angemeldet.' }; headers.Authorization = `Bearer ${session.token}`; + headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`; // Ersatz-Header: viele Hoster entfernen "Authorization" } const init = { method, headers }; if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index f054e0fb..d702d92f 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -220,3 +220,10 @@ tr.totalrow td { font-weight: 700; background: var(--panel2); border-top: 2px so .thread .kind { background: var(--panel2); border-radius: 999px; padding: 0 8px; font-size: 11px; font-weight: 600; } .thread.note .kind { background: rgba(217,130,0,.18); color: var(--warn); } .thread.cust .kind { background: rgba(47,160,90,.16); color: var(--ok); } .composer { border-top: 3px solid var(--accent); } +.sigbox { margin: 8px 0 4px; } +.sigbox .row { align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } +.sigbox select { max-width: 280px; } +.sigpreview { border: 1px dashed var(--line); border-radius: 8px; padding: 8px 10px; font-size: .85rem; line-height: 1.5; color: var(--muted); background: var(--panel2); overflow: auto; max-height: 260px; } +.sigpreview.text { white-space: pre-wrap; } +.sigpreview p { margin: 0 0 6px; } .sigpreview p:last-child { margin-bottom: 0; } +.sigpreview img { max-width: 100%; height: auto; vertical-align: middle; } diff --git a/desktop/test/mock.html b/desktop/test/mock.html index adff0299..73fcaa4c 100644 --- a/desktop/test/mock.html +++ b/desktop/test/mock.html @@ -14,7 +14,7 @@ const routes = { '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 }] }), '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, threads: [ + 'ticket.php': () => ({ ticket: { ...tickets[0], source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', lockedByOtherName: null, 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

Max persönlich
', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '

Technik-Team Support

Linkalert(2)', 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: [] }] } }), @@ -25,7 +25,7 @@ const routes = { dimensions: [{ key: 'department', label: 'Abteilung' }, { key: 'month', label: 'Monat' }, { key: 'agent', label: 'Mitarbeiter' }], metrics: [{ key: 'total', label: 'Tickets gesamt', unit: '' }, { key: 'open', label: 'Offen', unit: '' }, { key: 'resolved', label: 'Gelöst/Geschlossen', unit: '' }, { key: 'sla_resolve', label: 'SLA Lösung', unit: '%' }], saved: [{ id: 1, name: 'Monatsbericht Support', isShared: true, mine: true, ownerName: 'Max Muster' }] }), - 'canned_responses.php': () => ({ items: [{ id: 1, title: 'Gruß', body: 'Hallo,
danke für Ihre Nachricht.', departmentId: null }] }), + 'canned_responses.php': () => ({ items: [{ id: 1, title: 'Gruß', body: 'Hallo,
danke für Ihre Nachricht.', departmentId: null }, { id: 2, title: 'Nur Technik', body: 'Technik-Text', departmentId: 1 }, { id: 3, title: 'Nur Vertrieb', body: 'Vertrieb-Text', departmentId: 2 }] }), 'customer.php': () => ({ customer: { name: 'Kunde 1', email: 'k1@mail.de' }, tickets }), 'teams.php': () => ({ staffOptions: [{ id: 1, name: 'Max Muster' }, { id: 2, name: 'Eva Beispiel' }], items: [{ id: 1, name: 'Support', leadId: 1, leadName: 'Max Muster', notes: '', isActive: true, memberCount: 2, memberIds: [1, 2] }] }), 'roles.php': () => ({ permissionKeys: ['reply_ticket', 'close_ticket', 'manage_teams'], defaultAgentPermissions: { reply_ticket: true }, items: [{ id: 1, name: 'Teamleitung', permissions: { reply_ticket: true }, staffCount: 2 }] }), diff --git a/desktop/test/run.js b/desktop/test/run.js index 8535e6e4..fb8e431a 100644 --- a/desktop/test/run.js +++ b/desktop/test/run.js @@ -1,86 +1,99 @@ -// UI-Tests der Desktop-App: lädt test/mock.html in einem unsichtbaren Electron-Fenster -// (mit Mock-API statt Server) und steuert die echte Oberfläche per Skript. -// Start: npm test -const { app, BrowserWindow } = require('electron'); -const path = require('path'); - -const failures = []; -let passed = 0; -const consoleErrors = []; - -function check(name, ok, detail) { - if (ok) { passed++; console.log(' ok ' + name); } - else { failures.push(name); console.log(' FAIL ' + name + (detail ? ' – ' + detail : '')); } -} - -app.whenReady().then(async () => { - const win = new BrowserWindow({ show: false, width: 1280, height: 800, webPreferences: { contextIsolation: false, nodeIntegration: false } }); - win.webContents.on('console-message', (_e, level, message) => { if (level >= 3) consoleErrors.push(message); }); - win.webContents.on('render-process-gone', (_e, d) => { failures.push('Renderer abgestürzt: ' + d.reason); }); - const run = (code) => win.webContents.executeJavaScript(code); - const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - const waitFor = async (code, ms = 4000) => { - const end = Date.now() + ms; - while (Date.now() < end) { if (await run(code).catch(() => false)) return true; await wait(50); } - return false; - }; - - try { - await win.loadFile(path.join(__dirname, 'mock.html')); - // Mock-Routen, die für die Admin-Seiten fehlen, liefern eine leere Liste statt eines Fehlers - await run(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; })()`); - - console.log('Start & Navigation'); - check('App startet und zeigt das Hauptgerüst', await waitFor(`!!document.querySelector('.side') && !!document.getElementById('main')`)); - check('Seitenleiste enthält Hauptpunkte', await run(`['Übersicht','Tickets','Kunden'].every(t => document.querySelector('.side').innerText.includes(t))`)); - - await run(`go('dashboard')`); - check('Dashboard zeigt Kennzahlen', await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht')`)); - - 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('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('Interne Notiz ist gekennzeichnet', await run(`!!document.querySelector('.thread.note')`)); - - await run(`go('reports')`); - check('Berichte laden Tabelle mit Summen', await waitFor(`['Technik', 'Vertrieb', '59'].every(x => document.getElementById('main').innerText.includes(x))`)); - - console.log('Verwaltung'); - await run(`state.staff.role = 'admin'`); - const sections = await run(`adminSections().map(s => s.key)`); - check('Verwaltungsbereiche vorhanden', sections.length >= 10, 'gefunden: ' + sections.length); - for (const key of sections) { - await run(`go('admin', '${key}')`); - const ok = await waitFor(`document.getElementById('main').children.length > 0 && !document.querySelector('#main .warnbox')`, 3000); - check('Verwaltung „' + key + '“ rendert ohne Fehler', ok, await run(`(document.querySelector('#main .warnbox')||{}).innerText || ''`)); - } - - console.log('Theme'); - await run(`window.deskhub.setSettings({ theme: 'light' }).then(() => { state.prefs = { ...state.prefs, theme: 'light' }; applyTheme(); })`); - check('Hell-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'light'`)); - await run(`window.deskhub.setSettings({ theme: 'dark' }).then(() => { state.prefs = { ...state.prefs, theme: 'dark' }; applyTheme(); })`); - check('Dunkel-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'dark'`)); - - console.log('Verbindung & Überwachung'); - await run(`go('dashboard')`); - await run(`window.__origApi = window.deskhub.api; window.deskhub.api = async () => ({ error: { code: 'NETWORK', status: 0, message: 'Server nicht erreichbar.' } }); pollActivity()`); - check('Fehlende Verbindung zeigt Banner', await waitFor(`document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); - check('Banner bietet „Jetzt erneut versuchen“', await run(`document.getElementById('banners').innerText.includes('Jetzt erneut versuchen')`)); - await run(`window.deskhub.api = window.__origApi; retryNow()`); - check('Banner verschwindet nach Wiederherstellung', await waitFor(`!document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); - await run(`window.__health = [{ level: 'error', code: 'mailbox_down', message: 'Postfach „support@example.de“ ist nicht erreichbar.' }]; pollActivity()`); - check('Serverstörung (Mailabruf) wird als Banner gezeigt', await waitFor(`document.getElementById('banners').innerText.includes('support@example.de')`, 6000)); - - check('Keine JavaScript-Fehler in der Konsole', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ')); - } catch (e) { - failures.push('Testlauf abgebrochen: ' + e.message); - console.log(' FAIL ' + e.message); - } - - console.log(`\n${passed} bestanden, ${failures.length} fehlgeschlagen`); - app.exit(failures.length ? 1 : 0); -}); +// UI-Tests der Desktop-App: lädt test/mock.html in einem unsichtbaren Electron-Fenster +// (mit Mock-API statt Server) und steuert die echte Oberfläche per Skript. +// Start: npm test +const { app, BrowserWindow } = require('electron'); +const path = require('path'); + +const failures = []; +let passed = 0; +const consoleErrors = []; + +function check(name, ok, detail) { + if (ok) { passed++; console.log(' ok ' + name); } + else { failures.push(name); console.log(' FAIL ' + name + (detail ? ' – ' + detail : '')); } +} + +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: false, width: 1280, height: 800, webPreferences: { contextIsolation: false, nodeIntegration: false } }); + win.webContents.on('console-message', (_e, level, message) => { if (level >= 3) consoleErrors.push(message); }); + win.webContents.on('render-process-gone', (_e, d) => { failures.push('Renderer abgestürzt: ' + d.reason); }); + const run = (code) => win.webContents.executeJavaScript(code); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const waitFor = async (code, ms = 4000) => { + const end = Date.now() + ms; + while (Date.now() < end) { if (await run(code).catch(() => false)) return true; await wait(50); } + return false; + }; + + try { + await win.loadFile(path.join(__dirname, 'mock.html')); + // Mock-Routen, die für die Admin-Seiten fehlen, liefern eine leere Liste statt eines Fehlers + await run(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; })()`); + + console.log('Start & Navigation'); + check('App startet und zeigt das Hauptgerüst', await waitFor(`!!document.querySelector('.side') && !!document.getElementById('main')`)); + check('Seitenleiste enthält Hauptpunkte', await run(`['Übersicht','Tickets','Kunden'].every(t => document.querySelector('.side').innerText.includes(t))`)); + + await run(`go('dashboard')`); + check('Dashboard zeigt Kennzahlen', await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht')`)); + + 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('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('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')`); + check('Textbausteine: Abteilung des Tickets und „für alle“ sind da, fremde Abteilungen nicht', await run(`(() => { const s = [...document.querySelectorAll('.composer select')].find((x) => x.options[0] && x.options[0].text.startsWith('Textbaustein')); const t = [...s.options].map((o) => o.text); return t.includes('Nur Technik') && t.includes('Gruß') && !t.includes('Nur Vertrieb'); })()`)); + check('Textbausteine sind nach Abteilung gruppiert', await run(`(() => { const g = [...document.querySelectorAll('.composer optgroup')].map((x) => x.label); return g.includes('Abteilung Technik') && g.includes('Für alle Abteilungen'); })()`)); + check('Signatur-Auswahl mit Voreinstellung „Meine Signatur“ und Vorschau', await run(`(() => { const b = document.querySelector('.composer .sigbox'); return !!b && b.querySelector('select').value === 'own' && b.querySelector('.sigpreview').textContent.includes('Max persönlich'); })()`)); + check('Angaben des Kunden (Zusatzfelder) werden angezeigt', await run(`(() => { const t = document.body.textContent; return t.includes('Angaben des Kunden') && t.includes('Titel der Geschichte') && t.includes('Der kleine Bär') && t.includes('Nutzerbedingungen'); })()`)); + check('Absätze der Signatur bleiben getrennt (nicht „GrüßeMax“)', await run(`(() => { const t = document.querySelector('.composer .sigpreview'); return t.children.length >= 2 && t.innerText.includes('Grüße' + String.fromCharCode(10)) && !t.innerText.includes('GrüßeMax'); })()`)); + await run(`(() => { const s = document.querySelector('.composer .sigbox select'); s.value = 'dept'; s.dispatchEvent(new Event('change')); })()`); + check('Abteilungs-Signatur zeigt Vorschau mit Text und Bild', await run(`(() => { const b = document.querySelector('.composer .sigpreview'); const i = b.querySelector('img'); return b.textContent.includes('Technik-Team Support') && !!i && i.getAttribute('src').startsWith('https://') && i.getAttribute('width') === '120'; })()`)); + check('Vorschau enthält nichts Gefährliches (Skripte, Handler, Stile, javascript-Links)', await run(`(() => { const b = document.querySelector('.composer .sigpreview'); return !b.querySelector('script') && !b.querySelector('[onerror]') && !b.querySelector('[style]') && !b.querySelector('a[href]'); })()`)); + await run(`(() => { const s = document.querySelector('.composer .sigbox select'); s.value = 'none'; s.dispatchEvent(new Event('change')); })()`); + check('„Keine Signatur“ wird in der Vorschau erklärt', await run(`document.querySelector('.composer .sigpreview').textContent.includes('keine Signatur')`)); + check('Signatur-Auswahl erscheint nur bei Antworten, nicht bei internen Notizen', await run(`(() => { const tab = [...document.querySelectorAll('.composer .tab')].find((x) => x.textContent.includes('Notiz')); if (!tab) return true; tab.click(); const hidden = document.querySelector('.composer .sigbox').classList.contains('hidden'); [...document.querySelectorAll('.composer .tab')].find((x) => x.textContent.includes('Antwort')).click(); return hidden; })()`)); + + await run(`go('reports')`); + check('Berichte laden Tabelle mit Summen', await waitFor(`['Technik', 'Vertrieb', '59'].every(x => document.getElementById('main').innerText.includes(x))`)); + + console.log('Verwaltung'); + await run(`state.staff.role = 'admin'`); + const sections = await run(`adminSections().map(s => s.key)`); + check('Verwaltungsbereiche vorhanden', sections.length >= 10, 'gefunden: ' + sections.length); + for (const key of sections) { + await run(`go('admin', '${key}')`); + const ok = await waitFor(`document.getElementById('main').children.length > 0 && !document.querySelector('#main .warnbox')`, 3000); + check('Verwaltung „' + key + '“ rendert ohne Fehler', ok, await run(`(document.querySelector('#main .warnbox')||{}).innerText || ''`)); + } + + console.log('Theme'); + await run(`window.deskhub.setSettings({ theme: 'light' }).then(() => { state.prefs = { ...state.prefs, theme: 'light' }; applyTheme(); })`); + check('Hell-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'light'`)); + await run(`window.deskhub.setSettings({ theme: 'dark' }).then(() => { state.prefs = { ...state.prefs, theme: 'dark' }; applyTheme(); })`); + check('Dunkel-Modus setzt data-theme', await waitFor(`document.documentElement.dataset.theme === 'dark'`)); + + console.log('Verbindung & Überwachung'); + await run(`go('dashboard')`); + await run(`window.__origApi = window.deskhub.api; window.deskhub.api = async () => ({ error: { code: 'NETWORK', status: 0, message: 'Server nicht erreichbar.' } }); pollActivity()`); + check('Fehlende Verbindung zeigt Banner', await waitFor(`document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); + check('Banner bietet „Jetzt erneut versuchen“', await run(`document.getElementById('banners').innerText.includes('Jetzt erneut versuchen')`)); + await run(`window.deskhub.api = window.__origApi; retryNow()`); + check('Banner verschwindet nach Wiederherstellung', await waitFor(`!document.getElementById('banners').innerText.includes('Keine Verbindung zum Server')`, 6000)); + await run(`window.__health = [{ level: 'error', code: 'mailbox_down', message: 'Postfach „support@example.de“ ist nicht erreichbar.' }]; pollActivity()`); + check('Serverstörung (Mailabruf) wird als Banner gezeigt', await waitFor(`document.getElementById('banners').innerText.includes('support@example.de')`, 6000)); + + check('Keine JavaScript-Fehler in der Konsole', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ')); + } catch (e) { + failures.push('Testlauf abgebrochen: ' + e.message); + console.log(' FAIL ' + e.message); + } + + console.log(`\n${passed} bestanden, ${failures.length} fehlgeschlagen`); + app.exit(failures.length ? 1 : 0); +});