Files
2026-11-01 11:15:09 +00:00

153 lines
13 KiB
JavaScript

// 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
}