feat: Admin-Bereich im Kundenportal (Ticketsystem-Login, Projektsuche, Kundenverwaltung)

- Login: Appwrite-User mit Label 'admin' (Ticketsystem-Mitarbeiter) bekommen
  eine Admin-Session und landen auf /admin.html
- Admin-Dashboard: Statistik-Uebersicht, Projektsuche ueber alle Projekte
  (inkl. Preview-/Gitea-Links, Vorschau ohne Kunden-Login), Kundenliste mit
  Portal-Status, letztem Login und Projektanzahl
- Kundennummer (code) direkt im Admin-Bereich pflegbar
- 'Als Kunde ansehen': Admin sieht das Portal exakt wie der Kunde (Banner
  mit Rueckweg zur Admin-Uebersicht)
- Kunden-Dashboard: neue Bereiche 'Meine Rechnungen' (InvoiceNinja inkl.
  Link ins Rechnungsportal) und 'Was wurde gemacht' (Tickets + Worksheets
  aus dem Ticketsystem, ohne interne Akquise-Tickets); Kundennummer im Header
- Admin-API /api/portal-admin/* (Session-basiert), Kunden-API /api/invoices
  und /api/activity
- appwriteClient: uebrig gebliebene Debug-Instrumentierung entfernt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:09:39 +00:00
parent 0320042045
commit 177ee6a13d
12 changed files with 1085 additions and 54 deletions

299
public/admin.js Normal file
View File

@@ -0,0 +1,299 @@
/* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 '<span class="pill pill-warn">Lead</span>'
if (s === 'lost') return '<span class="pill pill-muted">Abgesagt</span>'
return '<span class="pill pill-ok">Fester Kunde</span>'
}
function statusPill(project) {
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
if (['ready', 'deployed'].includes(s)) return '<span class="pill pill-ok">deployed</span>'
if (s.includes('fail')) return '<span class="pill pill-red">Fehler</span>'
if (!project.subdomain) return '<span class="pill pill-muted">ohne Website</span>'
return `<span class="pill pill-warn">${escapeHtml(project.status || 'pending')}</span>`
}
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) => `<div class="stat-tile"><span class="num">${s.num}</span><span class="lbl">${s.lbl}</span></div>`).join('')
logins.innerHTML = o.recentLogins.length
? o.recentLogins.map((l) => `<li>${escapeHtml(l.customerName)} <span class="muted"> ${fmtDateTime(l.lastLoginAt)}</span></li>`).join('')
: '<li class="muted">Noch keine Portal-Logins.</li>'
recent.innerHTML = o.recentProjects.length
? o.recentProjects.map((p) => `<li>${escapeHtml(p.projectName)}${p.customerName ? ` <span class="muted">(${escapeHtml(p.customerName)})</span>` : ''} <span class="muted"> ${fmtDateTime(p.updatedAt)}</span></li>`).join('')
: '<li class="muted">Keine Projekte.</li>'
} 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 = '<tr><td colspan="6" class="muted">Keine Projekte gefunden.</td></tr>'
return
}
tbody.innerHTML = projects.map((p) => `
<tr>
<td>${escapeHtml(p.projectName || p.repoFullName || '')}</td>
<td>${p.subdomain ? escapeHtml(p.subdomain) : '<span class="muted"></span>'}</td>
<td>${p.customerName ? escapeHtml(p.customerName) : '<span class="muted"></span>'}</td>
<td>${statusPill(p)}</td>
<td>${p.projectType === 'repo' ? 'Repo' : 'Website'}</td>
<td>
${p.previewUrl ? `<a class="plain" href="${escapeHtml(p.previewUrl)}" target="_blank" rel="noreferrer">Vorschau</a>` : ''}
${p.giteaRepoUrl ? ` · <a class="plain" href="${escapeHtml(p.giteaRepoUrl)}" target="_blank" rel="noreferrer">Gitea</a>` : ''}
</td>
</tr>`).join('')
} catch (err) {
tbody.innerHTML = `<tr><td colspan="6" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
/* --------------------------------------------------------------- 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 '<p class="muted">Keine Tickets für diesen Kunden.</p>'
return tickets.map((t) => `
<div class="detail-box">
<strong>${escapeHtml(t.woid ? `#${t.woid}` : '')} ${escapeHtml(t.topic || '')}</strong>
<span class="pill pill-muted">${escapeHtml(t.status || '')}</span>
<span class="muted">${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}</span>
${(t.worksheets || []).map((w) => `
<div class="ws-line">→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}</div>
`).join('')}
</div>`).join('')
}
function renderInvoices(data) {
if (!data.linked) return '<p class="muted">Kunde ist nicht mit InvoiceNinja verknüpft.</p>'
if (!data.invoices.length) return '<p class="muted">Keine Rechnungen vorhanden.</p>'
return `<table class="admin-table"><thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead><tbody>
${data.invoices.map((i) => `
<tr>
<td>${escapeHtml(i.number)}</td>
<td>${fmtDate(i.date)}</td>
<td>${fmtEuro(i.amount)}</td>
<td>${fmtEuro(i.balance)}</td>
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td>${i.portalUrl ? `<a class="plain" href="${escapeHtml(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen</a>` : ''}</td>
</tr>`).join('')}
</tbody></table>`
}
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 = `<td colspan="7"><div class="detail-box muted">Lade…</div></td>`
row.after(tr)
try {
if (kind === 'activity') {
const { tickets } = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/activity`)
tr.innerHTML = `<td colspan="7"><h4>Tickets &amp; Arbeiten</h4>${renderActivity(tickets)}</td>`
} else {
const data = await api(`/api/portal-admin/customers/${encodeURIComponent(customerId)}/invoices`)
tr.innerHTML = `<td colspan="7"><h4>Rechnungen</h4>${renderInvoices(data)}</td>`
}
} catch (err) {
tr.innerHTML = `<td colspan="7"><span class="error">${escapeHtml(err.message)}</span></td>`
}
}
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 = '<tr><td colspan="7" class="muted">Keine Kunden gefunden.</td></tr>'
return
}
tbody.innerHTML = customers.map((c) => `
<tr data-customer-row="${escapeHtml(c.id)}">
<td style="white-space:nowrap">
<input class="code-input" data-code-for="${escapeHtml(c.id)}" value="${escapeHtml(c.code)}" placeholder="">
<button class="mini-btn" data-save-code="${escapeHtml(c.id)}" title="Kundennummer speichern">✓</button>
</td>
<td>
<strong>${escapeHtml(c.name)}</strong><br>
<span class="muted">${escapeHtml(c.email || '')}</span>
</td>
<td>${stagePill(c.customerStatus)}</td>
<td>${c.portalAccessEnabled ? '<span class="pill pill-ok">aktiv</span>' : '<span class="pill pill-muted">kein Zugang</span>'}</td>
<td>${fmtDateTime(c.lastLoginAt)}</td>
<td>${c.projectCount}</td>
<td style="white-space:nowrap">
<button class="mini-btn primary" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
</td>
</tr>`).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 = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
/* ----------------------------------------------------------------- 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