/* 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)}` } } /* ----------------------------------------------------------------- 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 } 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') }) }) 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() } window.initAdminPage = initAdminPage