Upload via GUI (69 Dateien)
This commit is contained in:
+43
-3
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user