Upload via GUI (69 Dateien)

This commit is contained in:
2026-10-19 18:06:20 +00:00
parent 06d4f785d8
commit 500d53241c
7 changed files with 179 additions and 118 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "deskhub",
"productName": "DeskHub",
"version": "2.1.0",
"version": "2.1.3",
"description": "DeskHub Desktop-Client für Mitarbeiter",
"main": "src/main.js",
"author": "M_Viper",
+43 -3
View File
@@ -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(/<br\s*\/?>/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('<body>' + 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),
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self' data:">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self' data: https:">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeskHub</title>
<link rel="stylesheet" href="styles.css">
+1
View File
@@ -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); }
+7
View File
@@ -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; }
+2 -2
View File
@@ -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: '<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: [] }] } }),
@@ -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,<br>danke für Ihre Nachricht.', departmentId: null }] }),
'canned_responses.php': () => ({ items: [{ id: 1, title: 'Gruß', body: 'Hallo,<br>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 }] }),
+13
View File
@@ -45,6 +45,19 @@ app.whenReady().then(async () => {
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))`));