// Ticket-Ansicht: Verlauf lesen, antworten, interne Notiz, Status, Zuweisung, Wiedervorlage, Beobachten. import { h, clear, icon, toast, errText } from '../lib/dom.js'; import { apiCall, apiBeacon, getMeta, openWeb, ticketPath, plainToHtml, timeAgo, fmtBytes, RESOLUTION_REASONS, STATUS_LABELS } from '../lib/core.js'; import { spinner, statusPill, prioPill, avatar } from '../lib/ui.js'; const pad = (n) => String(n).padStart(2, '0'); const sqlTime = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`; /** Zeitpunkte für die Wiedervorlage (nur künftige). */ export function snoozeChoices(now = new Date()) { const at = (days, hour) => { const d = new Date(now); d.setDate(d.getDate() + days); d.setHours(hour, 0, 0, 0); return d; }; const list = [['In 1 Stunde', new Date(now.getTime() + 3600 * 1000)], ['Heute 17:00', at(0, 17)], ['Morgen 08:00', at(1, 8)], ['In 3 Tagen', at(3, 8)], ['In einer Woche', at(7, 8)]]; return list.filter(([, d]) => d.getTime() > now.getTime() + 120 * 1000).map(([label, d]) => [label, sqlTime(d)]); } export async function renderTicket(host, ctx, { number }) { host.append(spinner()); let t; try { ({ data: { ticket: t } } = await apiCall('ticket.php', { query: { number } })); } catch (e) { clear(host); host.append(h('div', { class: 'msg err', role: 'alert' }, errText(e)), h('div', { class: 'actions', style: { marginTop: '10px' } }, h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((x) => toast(errText(x), 'err')) }, icon('external', 14), 'Im Browser öffnen'))); if (e.status === 401) ctx.logoutExpired(); return; } // Wer das Ticket geöffnet und dabei die Sperre erhalten hat, gibt sie beim Verlassen wieder frei. if (!t.lockedBy && !t.lockedByOtherName) ctx.onLeave(() => apiBeacon(ctx.session, 'ticket_unlock.php', { number })); // läuft auch weiter, wenn sich das Popup schließt const meta = await getMeta().catch(() => ({ agents: [], departments: [], priorities: [] })); const reload = () => { clear(host); return renderTicket(host, ctx, { number }); }; const perms = t.permissions || {}; const me = ctx.session.staff?.id; async function update(body, okText) { try { await apiCall('ticket_update.php', { method: 'POST', body: { number, ...body } }); toast(okText); await reload(); } catch (e) { toast(errText(e), 'err'); } } // ─ Kopf const head = h('div', { class: 'card tdetail' }, h('h1', {}, t.subject), h('div', { class: 'facts' }, statusPill(t.status), prioPill(t.priority), h('span', { class: 'pill' }, `#${t.number}`), t.isOverdue ? h('span', { class: 'pill', style: { background: 'var(--bad-bg)', color: 'var(--bad)' } }, t.dueLabel || 'überfällig') : (t.dueLabel ? h('span', { class: 'pill' }, t.dueLabel) : null), t.snoozedUntil ? h('span', { class: 'pill' }, icon('clock', 12), `Wiedervorlage: ${timeAgo(t.snoozedUntil)}`) : null), h('div', { class: 'muted', style: { marginTop: '8px' } }, `${t.requesterName}${t.requesterEmail ? ` <${t.requesterEmail}>` : ''}`, h('br'), `${t.department?.name || ''} · ${t.assignedStaff ? `zugewiesen an ${t.assignedStaff.name}` : 'nicht zugewiesen'} · erstellt ${timeAgo(t.createdAt)}`)); const notices = []; if (t.lockedBy) notices.push(h('div', { class: 'msg warn' }, `Wird gerade von ${t.lockedBy} bearbeitet (Admin-Zugriff).`)); if (t.lockedByOtherName) notices.push(h('div', { class: 'msg warn' }, `Dieser Chat wird gerade von ${t.lockedByOtherName} bearbeitet. Du kannst zuschauen.`)); if (t.isSpam) notices.push(h('div', { class: 'msg warn' }, 'Als Spam markiert.')); // ─ Schnellaktionen const actions = h('div', { class: 'actions' }); if (perms.assign_ticket && t.assignedStaff?.id !== me) actions.append(h('button', { class: 'btn small', onclick: () => update({ assignedStaffId: me }, 'Dir zugewiesen') }, icon('user', 14), 'Mir zuweisen')); actions.append(h('button', { class: 'btn small', onclick: () => update({ following: !t.following }, t.following ? 'Beobachten beendet' : 'Du beobachtest das Ticket') }, icon('eye', 14), t.following ? 'Nicht mehr beobachten' : 'Beobachten')); const snoozeSel = h('select', { 'aria-label': 'Wiedervorlage', onchange: (ev) => { const v = ev.target.value; if (!v) return; update({ snoozeUntil: v === 'clear' ? null : v }, v === 'clear' ? 'Wiedervorlage aufgehoben' : 'Wiedervorlage gesetzt'); } }, h('option', { value: '' }, 'Wiedervorlage …'), snoozeChoices().map(([label, v]) => h('option', { value: v }, label)), t.snoozedUntil ? h('option', { value: 'clear' }, 'Aufheben') : null); actions.append(h('div', { style: { minWidth: '150px' } }, snoozeSel)); actions.append(h('button', { class: 'btn small', onclick: () => openWeb(ticketPath(number)).catch((e) => toast(errText(e), 'err')) }, icon('external', 14), 'Im Browser')); // ─ Eigenschaften const statusSel = h('select', { 'aria-label': 'Status' }, Object.entries(STATUS_LABELS).map(([v, l]) => h('option', { value: v, selected: v === t.status ? '' : undefined }, l))); const reasonSel = h('select', { 'aria-label': 'Abschlussgrund' }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l))); const reasonRow = h('div', { hidden: true }, reasonSel); const syncReason = () => { reasonRow.hidden = !['resolved', 'closed'].includes(statusSel.value); }; statusSel.addEventListener('change', syncReason); const assignSel = h('select', { 'aria-label': 'Zuweisen' }, h('option', { value: '' }, 'Niemand'), (meta.agents || []).map((a) => h('option', { value: a.id, selected: a.id === t.assignedStaff?.id ? '' : undefined }, a.name))); const props = h('div', { class: 'card' }, h('h2', {}, 'Eigenschaften'), h('div', { class: 'stack' }, h('div', { class: 'row2' }, h('label', {}, 'Status', statusSel), perms.assign_ticket ? h('label', {}, 'Zugewiesen an', assignSel) : null), reasonRow, h('div', { class: 'actions' }, h('button', { class: 'btn small primary', onclick: async () => { const body = {}; if (statusSel.value !== t.status) { body.status = statusSel.value; if (['resolved', 'closed'].includes(statusSel.value)) { if (!reasonSel.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = reasonSel.value; } } const wanted = assignSel.value === '' ? null : Number(assignSel.value); if (perms.assign_ticket && wanted !== (t.assignedStaff?.id ?? null)) body.assignedStaffId = wanted; if (!Object.keys(body).length) return toast('Nichts geändert.'); update(body, 'Gespeichert'); } }, 'Speichern')))); syncReason(); // ─ Verlauf const thread = h('div', { class: 'stack' }, (t.threads || []).map((m) => { const kind = m.isInternalNote ? 'note' : m.authorType === 'system' ? 'system' : m.authorType === 'staff' ? 'staff' : 'cust'; return h('div', { class: 'thread' }, h('div', { class: `bubble ${kind}` }, h('div', { class: 'who' }, kind === 'system' ? null : avatar(m.authorName, kind === 'cust'), h('span', {}, m.authorName), kind === 'note' ? h('span', { class: 'tag' }, 'Interne Notiz') : null, h('span', { class: 'when' }, timeAgo(m.createdAt))), h('div', { class: 'text' }, m.message), (m.attachments || []).length ? h('div', { class: 'files' }, m.attachments.map((a) => h('span', { class: 'file' }, icon('clip', 12), `${a.name} (${fmtBytes(a.size)})`))) : null, (m.comments || []).map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff?.name || ''), ' ', h('span', { class: 'muted' }, timeAgo(c.createdAt)), h('div', {}, c.message))))); })); // ─ Antwort schreiben const canReply = perms.reply_ticket !== false, canNote = perms.post_note !== false; let mode = canReply ? 'reply' : 'note'; const text = h('textarea', { placeholder: 'Antwort schreiben … (Strg+Enter sendet)', 'aria-label': 'Nachricht' }); // Das Popup schließt sich beim Klick daneben: Der Entwurf bleibt deshalb erhalten, bis er gesendet wird. const draftKey = `draft:${number}`; // Der Entwurf merkt sich auch den Modus: Eine interne Notiz darf nach dem Wiederöffnen nicht als Antwort an den Kunden dastehen. const savedRaw = (await chrome.storage.local.get(draftKey))[draftKey]; const saved = typeof savedRaw === 'string' ? { text: savedRaw, mode: 'note' } : savedRaw; // ältere Entwürfe ohne Modus: sicherheitshalber als Notiz if (saved?.text) { text.value = saved.text; if (saved.mode === 'note' && canNote) mode = 'note'; else if (saved.mode === 'reply' && canReply) mode = 'reply'; } const saveDraft = () => { if (text.value) chrome.storage.local.set({ [draftKey]: { text: text.value, mode } }); else chrome.storage.local.remove(draftKey); }; text.addEventListener('input', saveDraft); const afterStatus = h('select', { 'aria-label': 'Status nach dem Senden' }, [['answered', 'Beantwortet'], ['pending', 'Wartet auf Kunde'], ['resolved', 'Gelöst'], ['closed', 'Geschlossen']].map(([v, l]) => h('option', { value: v }, l))); const sendReason = h('select', { 'aria-label': 'Abschlussgrund beim Senden', hidden: true }, h('option', { value: '' }, 'Abschlussgrund wählen …'), Object.entries(RESOLUTION_REASONS).map(([v, l]) => h('option', { value: v }, l))); afterStatus.addEventListener('change', () => { sendReason.hidden = !['resolved', 'closed'].includes(afterStatus.value); }); const sig = t.replySignatures || {}; const sigSel = (sig.own?.available || sig.department?.available) ? h('select', { 'aria-label': 'Signatur' }, h('option', { value: '' }, 'Signatur: Standard'), sig.own?.available ? h('option', { value: 'own' }, 'Meine Signatur') : null, sig.department?.available ? h('option', { value: 'dept' }, 'Abteilungs-Signatur') : null, h('option', { value: 'none' }, 'Ohne Signatur')) : null; let canned = null; const cannedSel = h('select', { 'aria-label': 'Textbaustein', onfocus: async () => { if (canned) return; try { canned = (await apiCall('canned_responses.php', { query: { departmentId: t.department?.id } })).data.items || []; } catch { canned = []; } cannedSel.append(...canned.map((c, i) => h('option', { value: i }, c.title))); }, onchange: () => { const c = canned?.[Number(cannedSel.value)]; if (c) { text.value = text.value ? `${text.value}\n${c.body}` : c.body; text.focus(); } cannedSel.value = ''; } }, h('option', { value: '' }, 'Textbaustein einfügen …')); const modeSeg = h('div', { class: 'seg' }); const send = h('button', { class: 'btn primary', type: 'button' }, icon('send', 15), 'Senden'); const replyOpts = h('div', { class: 'stack' }, h('div', { class: 'row2' }, cannedSel, afterStatus), sigSel, sendReason); function drawMode() { modeSeg.replaceChildren( canReply ? h('button', { type: 'button', class: mode === 'reply' ? 'on' : '', onclick: () => { mode = 'reply'; saveDraft(); drawMode(); } }, icon('send', 14), 'Antwort') : null, canNote ? h('button', { type: 'button', class: mode === 'note' ? 'on' : '', onclick: () => { mode = 'note'; saveDraft(); drawMode(); } }, icon('note', 14), 'Interne Notiz') : null); replyOpts.hidden = mode !== 'reply'; text.placeholder = mode === 'reply' ? 'Antwort an den Kunden … (Strg+Enter sendet)' : 'Interne Notiz, nur für Mitarbeiter … (Strg+Enter sendet)'; } async function submit() { const message = text.value.trim(); if (!message) return toast('Bitte eine Nachricht eingeben.', 'err'); const body = { number, type: mode, message: plainToHtml(message) }; if (mode === 'reply') { body.setStatus = afterStatus.value; if (sigSel?.value) body.signature = sigSel.value; if (['resolved', 'closed'].includes(afterStatus.value)) { if (!sendReason.value) return toast('Bitte einen Abschlussgrund wählen.', 'err'); body.resolutionCode = sendReason.value; } } send.disabled = true; try { await apiCall('ticket_message.php', { method: 'POST', body }); await chrome.storage.local.remove(draftKey); toast(mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); await reload(); } catch (e) { toast(errText(e), 'err'); send.disabled = false; } } send.addEventListener('click', submit); text.addEventListener('keydown', (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') { ev.preventDefault(); submit(); } }); drawMode(); const composer = (canReply || canNote) ? h('div', { class: 'composer' }, modeSeg, text, replyOpts, h('div', { class: 'actions', style: { justifyContent: 'flex-end' } }, send)) : h('div', { class: 'msg warn' }, 'Für dieses Ticket fehlt das Recht zum Antworten.'); clear(host).append(h('div', { class: 'stack' }, ...notices, head, actions, thread, composer, props)); thread.lastElementChild?.scrollIntoView({ block: 'start' }); // wie im Chat: die neueste Nachricht zuerst }