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
+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; }