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:
299
public/admin.js
Normal file
299
public/admin.js
Normal 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, '&')
|
||||
.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 '<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 & 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
|
||||
Reference in New Issue
Block a user