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:
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')) {
|
||||
|
||||
Reference in New Issue
Block a user