From 177ee6a13d9d2a62e6d2fa011b8a2c345a5b813e Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Sat, 11 Jul 2026 17:09:39 +0000 Subject: [PATCH] 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 --- public/admin.html | 129 ++++++++++++ public/admin.js | 299 ++++++++++++++++++++++++++++ public/app.js | 131 ++++++++++-- public/dashboard.html | 33 +++ server/index.js | 4 + server/middleware/session.js | 36 +++- server/routes/auth.js | 49 ++++- server/routes/customerData.js | 44 ++++ server/routes/portalAdmin.js | 261 ++++++++++++++++++++++++ server/services/appwriteClient.js | 27 --- server/services/customerActivity.js | 57 ++++++ server/services/invoiceNinja.js | 69 +++++++ 12 files changed, 1085 insertions(+), 54 deletions(-) create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 server/routes/customerData.js create mode 100644 server/routes/portalAdmin.js create mode 100644 server/services/customerActivity.js diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..716f194 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,129 @@ + + + + + + Admin – Webklar Kundenbereich + + + + + + +
+ +
+ + +
+
+ +
+

Admin-Übersicht

+ + +
+ + + +
+ + +
+
Lade…
+
+
+

Letzte Portal-Logins

+
  • Lade…
+
+
+

Zuletzt aktualisierte Projekte

+
  • Lade…
+
+
+
+ + +
+ +
+ + + + + +
ProjektSubdomainKundeStatusTypLinks
Lade…
+
+

Als Admin kannst du jede Projekt-Vorschau direkt öffnen – ohne Kunden-Login.

+
+ + +
+ +
+ + + + + +
Kundennr.KundeStatusPortalLetzter LoginProjekteAktionen
Lade…
+
+

Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.

+
+
+ + + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..33d7546 --- /dev/null +++ b/public/admin.js @@ -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, '"') +} + +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 ` + ${data.invoices.map((i) => ` + + + + + + + + `).join('')} +
    Nr.DatumBetragOffenStatus
    ${escapeHtml(i.number)}${fmtDate(i.date)}${fmtEuro(i.amount)}${fmtEuro(i.balance)}${escapeHtml(i.status)}${i.portalUrl ? `Ansehen` : ''}
    ` +} + +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 diff --git a/public/app.js b/public/app.js index 769857c..7b6c823 100644 --- a/public/app.js +++ b/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 = ` + Admin-Ansicht: Du siehst das Portal als ${escapeHtml(me.customer?.name || 'Kunde')}. + + ` + 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 = ` + + + + ${data.invoices.map((i) => ` + + + + + + + + `).join('')} + +
    Nr.DatumBetragOffenStatus
    ${escapeHtml(i.number)}${fmtDateShort(i.date)}${fmtEuro(i.amount)}${fmtEuro(i.balance)}${escapeHtml(i.status)}${i.portalUrl ? `Rechnung ansehen` : ''}
    ` + } 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) => ` +
    +
    + ${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')} + ${escapeHtml(t.status || '–')} + · ${fmtDateShort(t.createdAt)} +
    + ${(t.worksheets || []).map((w) => ` +
    → ${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))}` : ''}
    + `).join('')} +
    `).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')) { diff --git a/public/dashboard.html b/public/dashboard.html index 25d1560..9b43770 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -5,6 +5,27 @@ Meine Projekte – Webklar Kundenbereich +