/* Admin-Dashboard des Kundenportals (Ticketsystem-Mitarbeiter). */ async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const err = new Error(data.error || `Fehler ${response.status}`) err.status = response.status throw err } return data } function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function fmtDate(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtDateTime(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtEuro(value) { return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) } function stagePill(status) { const s = String(status || '').toLowerCase() if (s === 'lead') return 'Lead' if (s === 'lost') return 'Abgesagt' return 'Fester Kunde' } function statusPill(project) { const s = String(project.provisioningStatus || project.status || '').toLowerCase() if (['ready', 'deployed'].includes(s)) return 'deployed' if (s.includes('fail')) return 'Fehler' if (!project.subdomain) return 'ohne Website' return `${escapeHtml(project.status || 'pending')}` } function showError(message) { const el = document.getElementById('admin-error') el.textContent = message el.classList.remove('hidden') } /* ------------------------------------------------------------ Übersicht --- */ async function loadOverview() { const grid = document.getElementById('stat-grid') const logins = document.getElementById('recent-logins') const recent = document.getElementById('recent-projects') try { const o = await api('/api/portal-admin/overview') grid.innerHTML = [ { num: o.projects.total, lbl: 'Projekte gesamt' }, { num: o.projects.deployed, lbl: 'Websites online' }, { num: o.projects.withoutWebsite, lbl: 'Projekte ohne Website' }, { num: o.customers.total, lbl: 'Kunden gesamt' }, { num: o.customers.leads, lbl: 'Leads' }, { num: o.customers.portalEnabled, lbl: 'Portal-Zugänge' }, ].map((s) => `
${s.num}${s.lbl}
`).join('') logins.innerHTML = o.recentLogins.length ? o.recentLogins.map((l) => `
  • ${escapeHtml(l.customerName)} – ${fmtDateTime(l.lastLoginAt)}
  • `).join('') : '
  • Noch keine Portal-Logins.
  • ' recent.innerHTML = o.recentProjects.length ? o.recentProjects.map((p) => `
  • ${escapeHtml(p.projectName)}${p.customerName ? ` (${escapeHtml(p.customerName)})` : ''} – ${fmtDateTime(p.updatedAt)}
  • `).join('') : '
  • Keine Projekte.
  • ' } catch (err) { grid.innerHTML = '' showError(err.message) } } /* ------------------------------------------------------------- Projekte --- */ let projectSearchTimer = null async function loadProjects(search = '') { const tbody = document.getElementById('project-rows') try { const { projects } = await api(`/api/portal-admin/projects?search=${encodeURIComponent(search)}`) if (!projects.length) { tbody.innerHTML = 'Keine Projekte gefunden.' return } tbody.innerHTML = projects.map((p) => ` ${escapeHtml(p.projectName || p.repoFullName || '–')} ${p.subdomain ? escapeHtml(p.subdomain) : ''} ${p.customerName ? escapeHtml(p.customerName) : ''} ${statusPill(p)} ${p.projectType === 'repo' ? 'Repo' : 'Website'} ${p.previewUrl ? `Vorschau` : ''} ${p.giteaRepoUrl ? ` · Gitea` : ''} `).join('') } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } /* --------------------------------------------------------------- Kunden --- */ let customerSearchTimer = null async function saveCustomerCode(customerId, btn) { const input = document.querySelector(`input[data-code-for="${customerId}"]`) if (!input) return btn.disabled = true try { await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}`, { method: 'PATCH', body: JSON.stringify({ code: input.value }), }) btn.textContent = '✓ Gespeichert' setTimeout(() => { btn.textContent = '✓'; btn.disabled = false }, 1200) } catch (err) { btn.disabled = false alert('Kundennummer konnte nicht gespeichert werden: ' + err.message) } } async function viewAsCustomer(customerId) { try { await api(`/api/portal-admin/view-as/${encodeURIComponent(customerId)}`, { method: 'POST' }) window.location.href = '/dashboard.html' } catch (err) { alert('Ansicht konnte nicht gewechselt werden: ' + err.message) } } function renderActivity(tickets) { if (!tickets.length) return '

    Keine Tickets für diesen Kunden.

    ' return tickets.map((t) => `
    ${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '–')} ${escapeHtml(t.status || '–')} ${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)} ${(t.worksheets || []).map((w) => `
    → ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
    `).join('')}
    `).join('') } function renderInvoices(data) { if (!data.linked) return '

    Kunde ist nicht mit InvoiceNinja verknüpft.

    ' if (!data.invoices.length) return '

    Keine Rechnungen vorhanden.

    ' return ` ${data.invoices.map((i) => ` `).join('')}
    Nr.DatumBetragOffenStatus
    ${escapeHtml(i.number)} ${fmtDate(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} ${escapeHtml(i.status)} ${i.portalUrl ? `Ansehen` : ''}
    ` } async function toggleCustomerDetail(customerId, kind, cell) { const existing = document.getElementById(`detail-${customerId}`) if (existing) { const wasKind = existing.dataset.kind existing.remove() if (wasKind === kind) return } const row = document.querySelector(`tr[data-customer-row="${customerId}"]`) if (!row) return const tr = document.createElement('tr') tr.id = `detail-${customerId}` tr.dataset.kind = kind tr.className = 'detail-row' tr.innerHTML = `
    Lade…
    ` row.after(tr) try { if (kind === 'activity') { const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`) tr.innerHTML = `

    Tickets & Arbeiten

    ${renderActivity(tickets)}` } else { const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`) tr.innerHTML = `

    Rechnungen

    ${renderInvoices(data)}` } } catch (err) { tr.innerHTML = `${escapeHtml(err.message)}` } } async function loadCustomers(search = '') { const tbody = document.getElementById('customer-rows') try { const { customers } = await api(`/api/portal-admin/customers?search=${encodeURIComponent(search)}`) if (!customers.length) { tbody.innerHTML = 'Keine Kunden gefunden.' return } tbody.innerHTML = customers.map((c) => ` ${escapeHtml(c.name)}
    ${escapeHtml(c.email || '–')} ${stagePill(c.customerStatus)} ${c.portalAccessEnabled ? 'aktiv' : 'kein Zugang'} ${fmtDateTime(c.lastLoginAt)} ${c.projectCount} `).join('') tbody.querySelectorAll('[data-save-code]').forEach((btn) => btn.addEventListener('click', () => saveCustomerCode(btn.dataset.saveCode, btn))) tbody.querySelectorAll('[data-view-as]').forEach((btn) => btn.addEventListener('click', () => viewAsCustomer(btn.dataset.viewAs))) tbody.querySelectorAll('[data-activity]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity'))) tbody.querySelectorAll('[data-invoices]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices'))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } } /* ----------------------------------------------------------------- Chat --- */ const adminChat = { activeCustomerId: '', activeCustomerName: '', lastCreatedAt: '', threadTimer: null, } function updateAdminChatBadge(unread) { const badge = document.getElementById('admin-chat-badge') if (!badge) return if (unread > 0) { badge.textContent = unread badge.classList.remove('hidden') } else { badge.classList.add('hidden') } } async function pollAdminChatBadge() { if (document.hidden) return try { const { unread } = await api('/api/portal-admin/chats/unread-count') updateAdminChatBadge(unread) } catch { /* ignore */ } } async function loadChatList() { const list = document.getElementById('admin-chat-list') try { const { chats } = await api('/api/portal-admin/chats') if (!chats.length) { list.innerHTML = '
    Noch keine Unterhaltungen.
    ' return } list.innerHTML = chats.map((c) => `
    ${escapeHtml(c.customerName)} ${c.unreadFromCustomer ? `${c.unreadFromCustomer}` : ''}
    ${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}
    ${fmtDateTime(c.lastAt)}
    `).join('') list.querySelectorAll('[data-chat-customer]').forEach((el) => el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName))) } catch (err) { list.innerHTML = `
    ${escapeHtml(err.message)}
    ` } } function appendAdminChatMessages(messages) { const box = document.getElementById('admin-chat-messages') if (!box || !messages.length) return for (const msg of messages) { const div = document.createElement('div') div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}` div.innerHTML = `${escapeHtml(msg.text)}${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}` box.appendChild(div) if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt } box.scrollTop = box.scrollHeight } async function pollAdminThread() { if (!adminChat.activeCustomerId || document.hidden) return try { const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : '' const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`) if (messages.length) { appendAdminChatMessages(messages) await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {}) } } catch { /* naechster Poll */ } } async function sendAdminChatMessage() { const input = document.getElementById('admin-chat-input') const btn = document.getElementById('admin-chat-send') const text = input.value.trim() if (!text || !adminChat.activeCustomerId) return btn.disabled = true try { const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, { method: 'POST', body: JSON.stringify({ text }), }) input.value = '' appendAdminChatMessages([message]) loadChatList() } catch (err) { alert('Nachricht konnte nicht gesendet werden: ' + err.message) } finally { btn.disabled = false input.focus() } } async function openChatThread(customerId, customerName) { adminChat.activeCustomerId = customerId adminChat.activeCustomerName = customerName || 'Kunde' adminChat.lastCreatedAt = '' const thread = document.getElementById('admin-chat-thread') thread.innerHTML = `
    ${escapeHtml(adminChat.activeCustomerName)}
    ` document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage) document.getElementById('admin-chat-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendAdminChatMessage() } }) try { const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`) appendAdminChatMessages(messages) await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {}) updateAdminChatBadge(0) pollAdminChatBadge() loadChatList() } catch (err) { document.getElementById('admin-chat-messages').innerHTML = `${escapeHtml(err.message)}` } clearInterval(adminChat.threadTimer) adminChat.threadTimer = setInterval(pollAdminThread, 15000) } /* ----------------------------------------------------------------- Init --- */ async function initAdminPage() { document.getElementById('logout-btn')?.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }).catch(() => {}) window.location.href = '/login.html' }) try { const me = await api('/api/auth/me') if (!me.authenticated || me.role !== 'admin') { window.location.href = '/login.html' return } if (me.viewAs) { // Admin ist gerade in der Kundenansicht -> zurückholen await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) } document.getElementById('admin-meta').textContent = `${me.admin?.name || 'Admin'} (${me.admin?.email || ''})` } catch { window.location.href = '/login.html' return } let chatListLoaded = false document.querySelectorAll('.admin-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active')) document.querySelectorAll('.admin-section').forEach((s) => s.classList.remove('active')) tab.classList.add('active') document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active') if (tab.dataset.tab === 'chat' && !chatListLoaded) { chatListLoaded = true loadChatList() } }) }) document.getElementById('project-search')?.addEventListener('input', (e) => { clearTimeout(projectSearchTimer) projectSearchTimer = setTimeout(() => loadProjects(e.target.value.trim()), 250) }) document.getElementById('customer-search')?.addEventListener('input', (e) => { clearTimeout(customerSearchTimer) customerSearchTimer = setTimeout(() => loadCustomers(e.target.value.trim()), 250) }) loadOverview() loadProjects() loadCustomers() // Chat-Badge global pollen pollAdminChatBadge() setInterval(pollAdminChatBadge, 60000) } window.initAdminPage = initAdminPage