/* 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 `| Nr. | Datum | Betrag | Offen | Status | |
${data.invoices.map((i) => `
| ${escapeHtml(i.number)} |
${fmtDate(i.date)} |
${fmtEuro(i.amount)} |
${fmtEuro(i.balance)} |
${escapeHtml(i.status)} |
${i.portalUrl ? `Ansehen` : ''} |
`).join('')}
`
}
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