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>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { sessionMiddleware } from './middleware/session.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import projectsRoutes from './routes/projects.js'
|
||||
import featuresRoutes from './routes/features.js'
|
||||
import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
@@ -43,6 +45,8 @@ app.use((req, res, next) => {
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
app.use('/api/admin/customers', adminCustomersRoutes)
|
||||
|
||||
@@ -26,22 +26,36 @@ export function clearPortalSession(res) {
|
||||
})
|
||||
}
|
||||
|
||||
export function requireSession(req, res, next) {
|
||||
export function parsePortalSession(req) {
|
||||
const raw = req.signedCookies?.[config.cookieName]
|
||||
if (!raw) {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSession(req, res, next) {
|
||||
const session = parsePortalSession(req)
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Nicht angemeldet' })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = JSON.parse(raw)
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.status(401).json({ error: 'Ungültige Session' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
} catch {
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.status(401).json({ error: 'Ungültige Session' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
/** Admin-Session: Ticketsystem-Mitarbeiter (Appwrite-Label "admin") im Portal. */
|
||||
export function requireAdminSession(req, res, next) {
|
||||
const session = parsePortalSession(req)
|
||||
if (!session?.appwriteUserId || session.role !== 'admin') {
|
||||
return res.status(401).json({ error: 'Admin-Anmeldung erforderlich' })
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function getSessionCustomerId(req) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCustomerByEmail,
|
||||
getDocument,
|
||||
getPortalAccessByCustomerId,
|
||||
getUserById,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
@@ -147,6 +148,31 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
try {
|
||||
const user = await loginWithAppwrite(email.trim(), password)
|
||||
|
||||
// Ticketsystem-Admins (Appwrite-Label "admin") bekommen die Admin-Ansicht
|
||||
const account = await getUserById(user.$id).catch(() => null)
|
||||
if (Array.isArray(account?.labels) && account.labels.includes('admin')) {
|
||||
const adminName = account.name || 'Admin'
|
||||
const adminEmail = account.email || email.trim()
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
appwriteUserId: user.$id,
|
||||
name: adminName,
|
||||
email: adminEmail,
|
||||
})
|
||||
const adminPreviewToken = await createPreviewSessionToken({
|
||||
userId: user.$id,
|
||||
customerId: '',
|
||||
email: adminEmail,
|
||||
name: adminName,
|
||||
role: 'admin',
|
||||
labels: ['admin'],
|
||||
authSource: 'kundenbereich-admin',
|
||||
})
|
||||
res.cookie(PREVIEW_COOKIE_NAME, adminPreviewToken, previewSessionCookieOptions())
|
||||
return res.json({ success: true, role: 'admin', admin: { name: adminName, email: adminEmail } })
|
||||
}
|
||||
|
||||
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
|
||||
customer = await syncCustomerAppwriteUserId(customer, user.$id)
|
||||
|
||||
@@ -176,7 +202,7 @@ router.post('/login', async (req, res) => {
|
||||
console.warn('[auth] lastLoginAt update failed:', err.message)
|
||||
}
|
||||
|
||||
return res.json({ success: true, customer: sanitizeCustomer(customer) })
|
||||
return res.json({ success: true, role: 'customer', customer: sanitizeCustomer(customer) })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
if (status === 429) {
|
||||
@@ -279,6 +305,26 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
try {
|
||||
const session = JSON.parse(raw)
|
||||
|
||||
// Admin-Session (Ticketsystem-Mitarbeiter), optional im "Als Kunde ansehen"-Modus
|
||||
if (session.role === 'admin' && session.appwriteUserId) {
|
||||
let customer = null
|
||||
if (session.customerId) {
|
||||
const doc = await getDocument(config.collections.customers, session.customerId).catch(() => null)
|
||||
if (doc) customer = sanitizeCustomer(doc)
|
||||
}
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'admin',
|
||||
viewAs: Boolean(session.viewAs && customer),
|
||||
admin: {
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
},
|
||||
customer,
|
||||
})
|
||||
}
|
||||
|
||||
if (!session.customerId || !session.appwriteUserId) {
|
||||
return res.json({ authenticated: false })
|
||||
}
|
||||
@@ -304,6 +350,7 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
role: 'customer',
|
||||
customer: sanitizeCustomer(customer),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
44
server/routes/customerData.js
Normal file
44
server/routes/customerData.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** Rechnungen des eingeloggten Kunden (InvoiceNinja, sofern verknuepft). */
|
||||
router.get('/invoices', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
getSessionCustomerId(req)
|
||||
).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId)
|
||||
return res.json({ linked: true, invoices })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
|
||||
router.get('/activity', requireSession, async (req, res) => {
|
||||
try {
|
||||
const tickets = await getCustomerActivity(getSessionCustomerId(req), {
|
||||
ticketLimit: 25,
|
||||
// Interne Akquise-Tickets gehoeren nicht in die Kundenansicht
|
||||
excludeTypes: ['Akquise'],
|
||||
})
|
||||
return res.json({ tickets })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
261
server/routes/portalAdmin.js
Normal file
261
server/routes/portalAdmin.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
parsePortalSession,
|
||||
requireAdminSession,
|
||||
setPortalSession,
|
||||
} from '../middleware/session.js'
|
||||
import {
|
||||
getDocument,
|
||||
listDocuments,
|
||||
updateDocument,
|
||||
Query,
|
||||
} from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminSession)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
function isDeployed(project) {
|
||||
return ['ready', 'deployed'].includes(norm(project.provisioningStatus || project.status))
|
||||
}
|
||||
|
||||
function customerNameMap(customers) {
|
||||
const map = {}
|
||||
for (const c of customers) map[c.$id] = c.name || c.code || c.$id
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Übersicht --
|
||||
router.get('/overview', async (_req, res) => {
|
||||
try {
|
||||
const [projects, customers, access] = await Promise.all([
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
const stageOf = (c) => {
|
||||
const s = norm(c.customerStatus)
|
||||
if (s === 'lead') return 'lead'
|
||||
if (s === 'lost') return 'lost'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
const recentLogins = access
|
||||
.filter((a) => a.lastLoginAt)
|
||||
.sort((a, b) => String(b.lastLoginAt).localeCompare(String(a.lastLoginAt)))
|
||||
.slice(0, 5)
|
||||
.map((a) => ({
|
||||
customerName: nameById[a.customerId] || a.customerId,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
}))
|
||||
|
||||
const recentProjects = [...projects]
|
||||
.sort((a, b) =>
|
||||
String(b.updatedAt || b.$updatedAt || '').localeCompare(String(a.updatedAt || a.$updatedAt || ''))
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((p) => ({
|
||||
projectName: p.projectName || p.subdomain || p.repoFullName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
status: p.status || '',
|
||||
customerName: p.customerId ? nameById[p.customerId] || '' : '',
|
||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
projects: {
|
||||
total: projects.length,
|
||||
deployed: projects.filter(isDeployed).length,
|
||||
withoutWebsite: projects.filter((p) => !p.subdomain).length,
|
||||
assigned: projects.filter((p) => p.customerId).length,
|
||||
},
|
||||
customers: {
|
||||
total: customers.length,
|
||||
leads: customers.filter((c) => stageOf(c) === 'lead').length,
|
||||
fixed: customers.filter((c) => stageOf(c) === 'customer').length,
|
||||
portalEnabled: customers.filter((c) => c.portalAccessEnabled).length,
|
||||
},
|
||||
recentLogins,
|
||||
recentProjects,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Übersicht konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------- Projektsuche --
|
||||
router.get('/projects', async (req, res) => {
|
||||
const search = norm(req.query.search)
|
||||
try {
|
||||
const [projects, customers] = await Promise.all([
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
listDocuments(config.collections.customers, [Query.limit(500)]),
|
||||
])
|
||||
const nameById = customerNameMap(customers)
|
||||
|
||||
let items = projects.map((p) => ({
|
||||
id: p.$id,
|
||||
projectName: p.projectName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
previewUrl: p.previewUrl || '',
|
||||
liveDomain: p.liveDomain || '',
|
||||
status: p.status || '',
|
||||
provisioningStatus: p.provisioningStatus || '',
|
||||
projectType: p.projectType || (p.subdomain ? 'website' : 'repo'),
|
||||
giteaRepoUrl: p.giteaRepoUrl || '',
|
||||
repoFullName: p.repoFullName || '',
|
||||
customerId: p.customerId || '',
|
||||
customerName: p.customerId ? nameById[p.customerId] || p.customerId : '',
|
||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
||||
}))
|
||||
|
||||
if (search) {
|
||||
items = items.filter((p) =>
|
||||
[p.projectName, p.subdomain, p.repoFullName, p.customerName, p.status].some((v) =>
|
||||
norm(v).includes(search)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
items.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
||||
return res.json({ projects: items })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------- Kundenliste --
|
||||
router.get('/customers', async (req, res) => {
|
||||
const search = norm(req.query.search)
|
||||
try {
|
||||
const [customers, access, projects] = await Promise.all([
|
||||
listDocuments(config.collections.customers, [Query.orderAsc('name'), Query.limit(500)]),
|
||||
listDocuments(config.collections.customerPortalAccess, [Query.limit(500)]),
|
||||
listDocuments(config.collections.websiteProjects, [Query.limit(500)]),
|
||||
])
|
||||
|
||||
const lastLoginByCustomer = {}
|
||||
for (const a of access) {
|
||||
if (a.customerId && a.lastLoginAt) lastLoginByCustomer[a.customerId] = a.lastLoginAt
|
||||
}
|
||||
const projectCount = {}
|
||||
for (const p of projects) {
|
||||
if (p.customerId) projectCount[p.customerId] = (projectCount[p.customerId] || 0) + 1
|
||||
}
|
||||
|
||||
let items = customers.map((c) => ({
|
||||
id: c.$id,
|
||||
code: c.code || '',
|
||||
name: c.name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
location: c.location || '',
|
||||
customerStatus: c.customerStatus || '',
|
||||
portalAccessEnabled: Boolean(c.portalAccessEnabled),
|
||||
invoiceNinjaLinked: Boolean(c.invoiceNinjaClientId),
|
||||
lastLoginAt: lastLoginByCustomer[c.$id] || '',
|
||||
projectCount: projectCount[c.$id] || 0,
|
||||
}))
|
||||
|
||||
if (search) {
|
||||
items = items.filter((c) =>
|
||||
[c.code, c.name, c.email, c.location].some((v) => norm(v).includes(search))
|
||||
)
|
||||
}
|
||||
|
||||
return res.json({ customers: items })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------- Kundennummer/Stammdaten pflegen --
|
||||
router.patch('/customers/:customerId', async (req, res) => {
|
||||
const { code, location, phone } = req.body || {}
|
||||
const data = { updatedAt: new Date().toISOString() }
|
||||
if (code !== undefined) data.code = String(code).trim()
|
||||
if (location !== undefined) data.location = String(location).trim()
|
||||
if (phone !== undefined) data.phone = String(phone).trim()
|
||||
|
||||
try {
|
||||
const updated = await updateDocument(config.collections.customers, req.params.customerId, data)
|
||||
return res.json({
|
||||
success: true,
|
||||
customer: { id: updated.$id, code: updated.code || '', name: updated.name || '' },
|
||||
})
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------- Aktivität + Rechnungen eines Kunden --
|
||||
router.get('/customers/:customerId/activity', async (req, res) => {
|
||||
try {
|
||||
const tickets = await getCustomerActivity(req.params.customerId, { ticketLimit: 50 })
|
||||
return res.json({ tickets })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/customers/:customerId/invoices', async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId)
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId, { includeDrafts: true })
|
||||
return res.json({ linked: true, invoices })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- Als Kunde ansehen --
|
||||
router.post('/view-as/:customerId', async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, req.params.customerId).catch(() => null)
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
|
||||
const session = req.session
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
viewAs: true,
|
||||
appwriteUserId: session.appwriteUserId,
|
||||
adminName: session.adminName || session.name || 'Admin',
|
||||
adminEmail: session.adminEmail || session.email || '',
|
||||
customerId: customer.$id,
|
||||
name: customer.name || '',
|
||||
email: customer.email || '',
|
||||
})
|
||||
return res.json({ success: true, customer: { id: customer.$id, name: customer.name || '' } })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Ansicht konnte nicht gewechselt werden' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/exit-view-as', (req, res) => {
|
||||
const session = parsePortalSession(req) || req.session
|
||||
setPortalSession(res, {
|
||||
role: 'admin',
|
||||
appwriteUserId: session.appwriteUserId,
|
||||
name: session.adminName || session.name || 'Admin',
|
||||
email: session.adminEmail || session.email || '',
|
||||
})
|
||||
return res.json({ success: true })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,22 +1,5 @@
|
||||
import { config } from '../config.js'
|
||||
|
||||
const DEBUG_LOG = (location, message, data, hypothesisId) => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7281/ingest/30e8e71c-b377-4e72-84f9-593826c6d234', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '80bbfc' },
|
||||
body: JSON.stringify({
|
||||
sessionId: '80bbfc',
|
||||
location,
|
||||
message,
|
||||
data,
|
||||
hypothesisId,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
}
|
||||
|
||||
function appwriteHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -78,7 +61,6 @@ export async function loginWithAppwrite(email, password) {
|
||||
const now = Date.now()
|
||||
if (now < appwriteLoginBlockedUntil) {
|
||||
const waitSec = Math.ceil((appwriteLoginBlockedUntil - now) / 1000)
|
||||
DEBUG_LOG('appwriteClient.js:cooldown', 'login blocked locally', { waitSec }, 'H8')
|
||||
const error = new Error(
|
||||
`Zu viele Anmeldeversuche. Bitte warte noch ${waitSec} Sekunden.`
|
||||
)
|
||||
@@ -92,15 +74,7 @@ export async function loginWithAppwrite(email, password) {
|
||||
method: 'POST',
|
||||
body: { email, password },
|
||||
})
|
||||
DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession ok', {
|
||||
hasUserId: Boolean(session?.userId),
|
||||
sessionId: session?.$id || null,
|
||||
}, 'H6')
|
||||
} catch (err) {
|
||||
DEBUG_LOG('appwriteClient.js:session', 'createEmailPasswordSession fail', {
|
||||
message: err?.message?.slice(0, 120),
|
||||
code: err?.code,
|
||||
}, 'H1')
|
||||
if (err.status === 429) {
|
||||
appwriteLoginBlockedUntil = Date.now() + APPWRITE_RATE_LIMIT_COOLDOWN_MS
|
||||
}
|
||||
@@ -116,7 +90,6 @@ export async function loginWithAppwrite(email, password) {
|
||||
}
|
||||
|
||||
const user = { $id: session.userId, email, name: '' }
|
||||
DEBUG_LOG('appwriteClient.js:user', 'using session userId', { userId: user.$id }, 'H7')
|
||||
|
||||
clearLoginCooldown()
|
||||
return user
|
||||
|
||||
57
server/services/customerActivity.js
Normal file
57
server/services/customerActivity.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht").
|
||||
* excludeTypes: Ticket-Typen, die der Kunde nicht sehen soll (z. B. Akquise).
|
||||
*/
|
||||
export async function getCustomerActivity(customerId, { ticketLimit = 25, excludeTypes = [] } = {}) {
|
||||
if (!customerId) return []
|
||||
|
||||
let workorders = await listDocuments(config.collections.workorders, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(ticketLimit),
|
||||
])
|
||||
|
||||
if (excludeTypes.length) {
|
||||
const blocked = new Set(excludeTypes.map((t) => t.toLowerCase()))
|
||||
workorders = workorders.filter((w) => !blocked.has(String(w.type || '').toLowerCase()))
|
||||
}
|
||||
|
||||
const ids = workorders.map((w) => w.$id)
|
||||
let worksheets = []
|
||||
if (ids.length) {
|
||||
worksheets = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', ids),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(300),
|
||||
]).catch(() => [])
|
||||
}
|
||||
|
||||
const sheetsByWorkorder = {}
|
||||
for (const ws of worksheets) {
|
||||
if (ws.isComment) continue
|
||||
const list = (sheetsByWorkorder[ws.workorderId] ||= [])
|
||||
list.push({
|
||||
wsid: ws.wsid || '',
|
||||
employeeName: ws.employeeName || ws.employeeShort || '',
|
||||
serviceType: ws.serviceType || '',
|
||||
newStatus: ws.newStatus || '',
|
||||
totalTime: ws.totalTime || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
details: ws.details || '',
|
||||
createdAt: ws.createdAt || ws.$createdAt || '',
|
||||
})
|
||||
}
|
||||
|
||||
return workorders.map((w) => ({
|
||||
id: w.$id,
|
||||
woid: w.woid || '',
|
||||
topic: w.topic || w.title || '',
|
||||
status: w.status || '',
|
||||
type: w.type || '',
|
||||
createdAt: w.createdAt || w.$createdAt || '',
|
||||
worksheets: (sheetsByWorkorder[w.$id] || []).slice(0, 20),
|
||||
}))
|
||||
}
|
||||
@@ -35,6 +35,75 @@ export function sanitizeInvoiceClient(client) {
|
||||
}
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_LABELS = {
|
||||
1: 'Entwurf',
|
||||
2: 'Offen',
|
||||
3: 'Teilzahlung',
|
||||
4: 'Bezahlt',
|
||||
5: 'Storniert',
|
||||
6: 'Storniert',
|
||||
}
|
||||
|
||||
export function sanitizeInvoice(inv) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const balance = Number(inv.balance ?? 0)
|
||||
const statusId = Number(inv.status_id ?? 0)
|
||||
const overdue = balance > 0 && Boolean(inv.due_date) && inv.due_date < today && statusId !== 5
|
||||
const invitationKey = inv.invitations?.[0]?.key || ''
|
||||
|
||||
return {
|
||||
id: inv.id,
|
||||
number: inv.number || '',
|
||||
date: inv.date || '',
|
||||
dueDate: inv.due_date || '',
|
||||
amount: Number(inv.amount ?? 0),
|
||||
balance,
|
||||
statusId,
|
||||
status: overdue ? 'Überfällig' : INVOICE_STATUS_LABELS[statusId] || 'Offen',
|
||||
overdue,
|
||||
poNumber: inv.po_number || '',
|
||||
publicNotes: inv.public_notes || '',
|
||||
// Link ins InvoiceNinja-Kundenportal (Rechnung ansehen/zahlen)
|
||||
portalUrl: invitationKey ? `${config.invoiceNinja.url}/client/invoice/${invitationKey}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Rechnungen eines InvoiceNinja-Clients (fuer Kundenportal + Admin-Ansicht). */
|
||||
export async function listInvoicesForClient(clientId, { includeDrafts = false } = {}) {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
if (!clientId) return []
|
||||
|
||||
const url = new URL(`${config.invoiceNinja.url}/api/v1/invoices`)
|
||||
url.searchParams.set('client_id', String(clientId))
|
||||
url.searchParams.set('per_page', '100')
|
||||
url.searchParams.set('sort', 'date|desc')
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-API-TOKEN': token,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.message || 'Invoice Ninja API Fehler')
|
||||
error.status = response.status === 401 ? 502 : response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
return (data.data || [])
|
||||
.filter((inv) => !inv.is_deleted)
|
||||
.map(sanitizeInvoice)
|
||||
.filter((inv) => includeDrafts || inv.statusId !== 1)
|
||||
}
|
||||
|
||||
export async function listInvoiceNinjaClients() {
|
||||
const token = config.invoiceNinja.apiToken
|
||||
if (!token) {
|
||||
|
||||
Reference in New Issue
Block a user