452 lines
18 KiB
JavaScript
452 lines
18 KiB
JavaScript
/* 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>
|
||
<div class="cust-actions">
|
||
<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>
|
||
</div>
|
||
</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>
|
||
<div class="cust-actions">
|
||
<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>
|
||
</div>
|
||
</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>`
|
||
}
|
||
}
|
||
|
||
/* ----------------------------------------------------------------- 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 = '<div class="chat-list-item"><span class="muted">Noch keine Unterhaltungen.</span></div>'
|
||
return
|
||
}
|
||
list.innerHTML = chats.map((c) => `
|
||
<div class="chat-list-item${c.customerId === adminChat.activeCustomerId ? ' active' : ''}" data-chat-customer="${escapeHtml(c.customerId)}" data-chat-name="${escapeHtml(c.customerName)}">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||
<strong>${escapeHtml(c.customerName)}</strong>
|
||
${c.unreadFromCustomer ? `<span class="badge-pill">${c.unreadFromCustomer}</span>` : ''}
|
||
</div>
|
||
<div class="last">${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}</div>
|
||
<div class="last">${fmtDateTime(c.lastAt)}</div>
|
||
</div>`).join('')
|
||
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
|
||
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
|
||
} catch (err) {
|
||
list.innerHTML = `<div class="chat-list-item"><span class="error">${escapeHtml(err.message)}</span></div>`
|
||
}
|
||
}
|
||
|
||
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)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
|
||
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 = `
|
||
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
|
||
<div class="chat-messages" id="admin-chat-messages"></div>
|
||
<div class="chat-input-row">
|
||
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
|
||
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
|
||
</div>`
|
||
|
||
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 = `<span class="error">${escapeHtml(err.message)}</span>`
|
||
}
|
||
|
||
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
|