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:
129
public/admin.html
Normal file
129
public/admin.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<style>
|
||||
.admin-tabs { display: flex; gap: 8px; margin: 18px 0; flex-wrap: wrap; }
|
||||
.admin-tab {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: inherit; padding: 8px 16px; border-radius: 999px; cursor: pointer; font: inherit;
|
||||
}
|
||||
.admin-tab.active { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
.admin-section { display: none; }
|
||||
.admin-section.active { display: block; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.stat-tile { padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.stat-tile .num { font-size: 26px; font-weight: 700; display: block; }
|
||||
.stat-tile .lbl { font-size: 12px; opacity: 0.7; }
|
||||
.admin-search { width: 100%; max-width: 380px; padding: 9px 14px; border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: inherit; margin-bottom: 12px; }
|
||||
.admin-table-wrap { overflow-x: auto; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); }
|
||||
table.admin-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.admin-table th, .admin-table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: middle; }
|
||||
.admin-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; }
|
||||
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
|
||||
.pill-warn { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
|
||||
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.8; }
|
||||
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
|
||||
.mini-btn { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: inherit;
|
||||
padding: 5px 11px; border-radius: 8px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||
.mini-btn:hover { background: rgba(255,255,255,0.14); }
|
||||
.mini-btn.primary { background: rgba(94,234,212,0.16); border-color: rgba(94,234,212,0.5); }
|
||||
.code-input { width: 90px; padding: 4px 8px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.2);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit; font-size: 13px; }
|
||||
.detail-row td { background: rgba(255,255,255,0.03); }
|
||||
.detail-box { padding: 6px 4px; }
|
||||
.detail-box h4 { margin: 8px 0 4px; font-size: 13px; }
|
||||
.detail-box .muted { opacity: 0.65; }
|
||||
.ws-line { font-size: 13px; opacity: 0.85; margin: 2px 0 2px 14px; }
|
||||
a.plain { color: #7dd3fc; text-decoration: none; }
|
||||
a.plain:hover { text-decoration: underline; }
|
||||
.admin-note { font-size: 13px; opacity: 0.65; margin: 8px 0 0; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.recent-list { list-style: none; margin: 0; padding: 0; }
|
||||
.recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
|
||||
.recent-list li:last-child { border-bottom: none; }
|
||||
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
<div class="dot-pattern"></div>
|
||||
<div class="dot-fade"></div>
|
||||
</div>
|
||||
|
||||
<header class="portal-header">
|
||||
<a class="portal-logo" href="/admin.html">Webklar <span style="opacity:.6">Admin</span></a>
|
||||
<div class="portal-header-actions">
|
||||
<span class="portal-meta" id="admin-meta"></span>
|
||||
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<h1 class="portal-title">Admin-Übersicht</h1>
|
||||
<p id="admin-error" class="error hidden"></p>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="overview">Übersicht</button>
|
||||
<button class="admin-tab" data-tab="projects">Projekte</button>
|
||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
<section class="admin-section active" id="tab-overview">
|
||||
<div class="stat-grid" id="stat-grid"><span class="portal-status">Lade…</span></div>
|
||||
<div class="two-col">
|
||||
<div class="portal-card">
|
||||
<h3>Letzte Portal-Logins</h3>
|
||||
<ul class="recent-list" id="recent-logins"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
<div class="portal-card">
|
||||
<h3>Zuletzt aktualisierte Projekte</h3>
|
||||
<ul class="recent-list" id="recent-projects"><li class="muted">Lade…</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Projekte -->
|
||||
<section class="admin-section" id="tab-projects">
|
||||
<input class="admin-search" id="project-search" type="search"
|
||||
placeholder="Projekte suchen (Name, Subdomain, Repo, Kunde)…">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Projekt</th><th>Subdomain</th><th>Kunde</th><th>Status</th><th>Typ</th><th>Links</th></tr>
|
||||
</thead>
|
||||
<tbody id="project-rows"><tr><td colspan="6" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.</p>
|
||||
</section>
|
||||
|
||||
<!-- Kunden -->
|
||||
<section class="admin-section" id="tab-customers">
|
||||
<input class="admin-search" id="customer-search" type="search"
|
||||
placeholder="Kunden suchen (Kundennr., Name, E-Mail, Ort)…">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Kundennr.</th><th>Kunde</th><th>Status</th><th>Portal</th><th>Letzter Login</th><th>Projekte</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody id="customer-rows"><tr><td colspan="7" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
<script>
|
||||
initAdminPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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
|
||||
131
public/app.js
131
public/app.js
@@ -178,6 +178,10 @@ async function initLoginPage() {
|
||||
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
if (me.authenticated && me.customer) {
|
||||
await postLoginRedirect()
|
||||
return
|
||||
@@ -195,13 +199,17 @@ async function initLoginPage() {
|
||||
btn.setAttribute('aria-busy', 'true')
|
||||
let cooldownActive = false
|
||||
try {
|
||||
await api('/api/auth/login', {
|
||||
const result = await api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
})
|
||||
if (result.role === 'admin') {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
await postLoginRedirect()
|
||||
return
|
||||
} catch (err) {
|
||||
@@ -218,6 +226,83 @@ async function initLoginPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function fmtDateShort(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 fmtEuro(value) {
|
||||
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
}
|
||||
|
||||
function renderViewAsBanner(me) {
|
||||
const banner = document.getElementById('viewas-banner')
|
||||
if (!banner || !me.viewAs) return
|
||||
banner.classList.remove('hidden')
|
||||
banner.innerHTML = `
|
||||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||||
<button type="button" class="portal-btn-outline" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
|
||||
`
|
||||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
||||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||||
window.location.href = '/admin.html'
|
||||
})
|
||||
}
|
||||
|
||||
async function loadCustomerInvoices() {
|
||||
const section = document.getElementById('invoices-section')
|
||||
const body = document.getElementById('invoices-body')
|
||||
if (!section || !body) return
|
||||
try {
|
||||
const data = await api('/api/invoices')
|
||||
if (!data.linked || !data.invoices.length) return
|
||||
section.classList.remove('hidden')
|
||||
body.innerHTML = `
|
||||
<table class="simple-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>${fmtDateShort(i.date)}</td>
|
||||
<td>${fmtEuro(i.amount)}</td>
|
||||
<td>${fmtEuro(i.balance)}</td>
|
||||
<td>${escapeHtml(i.status)}</td>
|
||||
<td>${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Rechnung ansehen</a>` : ''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`
|
||||
} catch {
|
||||
/* Rechnungen sind optional */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerActivity() {
|
||||
const section = document.getElementById('activity-section')
|
||||
const body = document.getElementById('activity-body')
|
||||
if (!section || !body) return
|
||||
try {
|
||||
const { tickets } = await api('/api/activity')
|
||||
if (!tickets || !tickets.length) return
|
||||
section.classList.remove('hidden')
|
||||
body.innerHTML = tickets.map((t) => `
|
||||
<div class="activity-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')}</strong>
|
||||
<span class="activity-status">${escapeHtml(t.status || '–')}</span>
|
||||
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
|
||||
</div>
|
||||
${(t.worksheets || []).map((w) => `
|
||||
<div class="activity-ws">→ ${fmtDateShort(w.date)}${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''}${w.serviceType ? ` (${escapeHtml(w.serviceType)}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''})` : ''}${w.details ? `: ${escapeHtml(w.details.slice(0, 200))}` : ''}</div>
|
||||
`).join('')}
|
||||
</div>`).join('')
|
||||
} catch {
|
||||
/* Aktivität ist optional */
|
||||
}
|
||||
}
|
||||
|
||||
async function initDashboardPage() {
|
||||
const meta = document.getElementById('customer-meta')
|
||||
const list = document.getElementById('projects')
|
||||
@@ -232,30 +317,46 @@ async function initDashboardPage() {
|
||||
})
|
||||
|
||||
try {
|
||||
const [{ authenticated, customer }, { projects }, { features }] = await Promise.all([
|
||||
api('/api/auth/me'),
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
])
|
||||
const me = await api('/api/auth/me')
|
||||
|
||||
if (!authenticated || !customer) {
|
||||
if (!me.authenticated) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
if (me.role === 'admin' && !me.customer) {
|
||||
window.location.href = '/admin.html'
|
||||
return
|
||||
}
|
||||
if (!me.customer) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
|
||||
meta.textContent = customer.name ? `${customer.name} (${customer.email})` : customer.email
|
||||
const customer = me.customer
|
||||
renderViewAsBanner(me)
|
||||
|
||||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||||
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
|
||||
|
||||
const [{ projects }, { features }] = await Promise.all([
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
])
|
||||
|
||||
loading.classList.add('hidden')
|
||||
|
||||
if (!projects.length) {
|
||||
if (projects.length) {
|
||||
list.classList.remove('hidden')
|
||||
list.innerHTML = ''
|
||||
for (const project of projects) {
|
||||
list.appendChild(renderProjectCard(project, features))
|
||||
}
|
||||
} else {
|
||||
empty.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
list.classList.remove('hidden')
|
||||
list.innerHTML = ''
|
||||
for (const project of projects) {
|
||||
list.appendChild(renderProjectCard(project, features))
|
||||
}
|
||||
loadCustomerInvoices()
|
||||
loadCustomerActivity()
|
||||
} catch (err) {
|
||||
loading.classList.add('hidden')
|
||||
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
||||
|
||||
@@ -5,6 +5,27 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Meine Projekte – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<style>
|
||||
.viewas-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding: 10px 16px; margin-bottom: 16px; border-radius: 12px;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45);
|
||||
}
|
||||
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.simple-table th, .simple-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.simple-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||
.simple-table tr:last-child td { border-bottom: none; }
|
||||
.btn-link { color: #7dd3fc; text-decoration: none; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.activity-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.activity-item:last-child { border-bottom: none; }
|
||||
.activity-status {
|
||||
display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
|
||||
}
|
||||
.activity-ws { font-size: 13px; opacity: 0.8; margin: 3px 0 3px 14px; }
|
||||
.portal-section-title { margin: 28px 0 12px; font-size: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -21,12 +42,24 @@
|
||||
</header>
|
||||
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<div id="viewas-banner" class="viewas-banner hidden"></div>
|
||||
|
||||
<h1 class="portal-title">Meine Website-Projekte</h1>
|
||||
|
||||
<p id="load-error" class="error hidden"></p>
|
||||
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
|
||||
<ul id="projects" class="projects hidden"></ul>
|
||||
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
|
||||
|
||||
<section id="invoices-section" class="hidden">
|
||||
<h2 class="portal-section-title">Meine Rechnungen</h2>
|
||||
<div class="portal-card" id="invoices-body"></div>
|
||||
</section>
|
||||
|
||||
<section id="activity-section" class="hidden">
|
||||
<h2 class="portal-section-title">Was wurde gemacht</h2>
|
||||
<div class="portal-card" id="activity-body"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user