Files
Webklar-Kundenbereich/public/app.js
Kenso Grimm d4bd510dcd feat: Kundenportal-Ausbau - Tabs, Projekt-Einblicke, Git-Zeiterfassung, Rechnungen, Chat
Kunden-Dashboard:
- Tab-Navigation: Projekte | Was wurde gemacht | Rechnungen | Chat
- Projekt-Details pro Projekt: letzte Git-Commits (Titel+Beschreibung),
  Projektgroesse, Datei-Uebersicht (Top-Level aggregiert), Ticket-Arbeiten
- Rechnungen: gestylte Liste mit Status-Pillen, Ansehen/Zahlen-Link,
  PDF-Download ueber Server-Proxy (IDOR-geschuetzt)
- Chat mit dem Webklar-Team (Polling, Ungelesen-Badge, viewAs blockiert)

Admin-Dashboard:
- Chat-Tab: Konversationsliste + Thread + Antwortfeld, Ungelesen-Badge

Backend:
- giteaAdmin: getRepoInfo, listRepoCommits, getRepoTreeSummary
- projectInsights: 5-min-Cache, Invalidierung per Gitea-Webhook
- /api/projects/:id/git|files|work mit Ownership-Check (404)
- /api/chat/* (Kunde) + /api/portal-admin/chats/* (Admin), portalMessages-Collection
- mailer: E-Mail-Benachrichtigung an Admins bei Kundennachricht (15-min-Throttle)
- gitWorksheet: dd.mm.yyyy, voller Commit-Body, startTime leer (Zeit-Nachtrag),
  Auto-Webpage-Ticket bei Push auf Projekt ohne Ticket (ensureProjectTicket)
- customerActivity: Git-Push-Eintraege fuer Kunden sichtbar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:01:24 +00:00

639 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 fallback =
response.status === 502
? 'Server nicht erreichbar (502). Bitte kurz warten und Seite neu laden ggf. läuft ein Update auf project.webklar.com.'
: `Fehler ${response.status}`
const err = new Error(data.error || fallback)
err.status = response.status
if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds
throw err
}
return data
}
function showError(el, message) {
if (!el) return
el.textContent = message
el.classList.remove('hidden')
}
function hideError(el) {
if (el) el.classList.add('hidden')
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;')
}
function getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name) || ''
}
function fmtDateShort(value) {
if (!value) return ''
if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value)
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 fmtBytes(bytes) {
const n = Number(bytes || 0)
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`
return `${n} B`
}
function fmtMinutes(min) {
const n = Number(min || 0)
if (!n) return ''
const h = Math.floor(n / 60)
const m = n % 60
return h ? `${h} h ${m} min` : `${m} min`
}
/* -------------------------------------------------------------- Projekte --- */
function featuresForProject(features, projectId) {
return features.filter((f) => !f.projectId || f.projectId === projectId)
}
function renderProjectCard(project, features) {
const li = document.createElement('li')
li.className = 'project-card'
const projectFeatures = featuresForProject(features, project.id)
const featureHtml = projectFeatures.length
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
: ''
li.innerHTML = `
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
<dl>
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '')}</dd>
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : ''}</dd>
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : ''}</dd>
<dt>Status</dt><dd>${escapeHtml(project.status || '')}</dd>
</dl>
${featureHtml}
<div class="project-details-toggle">
<button type="button" class="portal-btn-outline" data-details-btn>Details anzeigen</button>
</div>
<div class="project-details hidden" data-details-box></div>
`
const btn = li.querySelector('[data-details-btn]')
const box = li.querySelector('[data-details-box]')
let loaded = false
btn.addEventListener('click', async () => {
const isHidden = box.classList.contains('hidden')
box.classList.toggle('hidden', !isHidden)
btn.textContent = isHidden ? 'Details ausblenden' : 'Details anzeigen'
if (isHidden && !loaded) {
loaded = true
await loadProjectDetails(project, box)
}
})
return li
}
function renderCommits(git) {
const sizeInfo = git.repo
? `<p class="muted">Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}</p>`
: ''
if (!git.commits || !git.commits.length) {
return `${sizeInfo}<p class="muted">Noch keine Änderungen vorhanden.</p>`
}
return sizeInfo + git.commits.map((c) => {
const [title, ...body] = String(c.message || '').trim().split('\n')
const bodyText = body.map((l) => l.trim()).filter(Boolean).join('\n')
return `
<div class="commit-item">
<strong>${escapeHtml(title)}</strong>
${bodyText ? `<div class="commit-body">${escapeHtml(bodyText)}</div>` : ''}
<div class="commit-meta">${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}</div>
</div>`
}).join('')
}
function renderFileOverview(files) {
if (!files || !files.totalFiles) {
return '<p class="muted">Keine Datei-Informationen verfügbar.</p>'
}
const rows = (files.topLevel || []).map((entry) => `
<tr>
<td>${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)}</td>
<td>${entry.fileCount}</td>
<td>${fmtBytes(entry.sizeBytes)}</td>
</tr>`).join('')
return `
<p class="muted">${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig sehr großes Projekt)' : ''}</p>
<table class="simple-table">
<thead><tr><th>Ordner / Datei</th><th>Dateien</th><th>Größe</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
}
function renderWorkEntries(work) {
if (!work.ticket) {
return '<p class="muted">Für dieses Projekt gibt es noch kein Ticket.</p>'
}
const head = `<p class="muted">Ticket #${escapeHtml(work.ticket.woid)}${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}</p>`
if (!work.worksheets.length) {
return `${head}<p class="muted">Noch keine dokumentierten Arbeiten.</p>`
}
return head + work.worksheets.map((ws) => `
<div class="activity-item">
<div>
${ws.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(ws.serviceType || 'Arbeit')}</span>`}
<strong> ${escapeHtml(ws.employeeName)}</strong>
<span class="muted"> · ${fmtDateShort(ws.date)}</span>
${ws.totalTime ? `<span class="pill pill-ok">${fmtMinutes(ws.totalTime)}</span>` : ''}
${ws.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
</div>
<div class="activity-ws">${escapeHtml(ws.details)}</div>
</div>`).join('')
}
async function loadProjectDetails(project, box) {
box.innerHTML = '<p class="muted">Lade Details…</p>'
const [git, files, work] = await Promise.all([
api(`/api/projects/${encodeURIComponent(project.id)}/git`).catch(() => null),
api(`/api/projects/${encodeURIComponent(project.id)}/files`).catch(() => null),
api(`/api/projects/${encodeURIComponent(project.id)}/work`).catch(() => null),
])
box.innerHTML = `
<h3>Letzte Änderungen</h3>
${git ? renderCommits(git) : '<p class="muted">Git-Daten derzeit nicht verfügbar.</p>'}
<h3>Datei-Übersicht</h3>
${files ? renderFileOverview(files) : '<p class="muted">Datei-Übersicht derzeit nicht verfügbar.</p>'}
<h3>Arbeiten am Projekt</h3>
${work ? renderWorkEntries(work) : '<p class="muted">Keine Ticket-Daten verfügbar.</p>'}
`
}
/* -------------------------------------------------------- Login-Redirects --- */
function safeRedirectUrl(url) {
try {
const u = new URL(url)
if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') {
return u.toString()
}
} catch {
/* ignore invalid URL */
}
return null
}
function isProjectPreviewReady(project) {
const status = (project?.provisioningStatus || project?.status || '').toLowerCase()
return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl)
}
function previewSubdomainFromUrl(url) {
try {
const host = new URL(url).hostname.toLowerCase()
const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/)
return match ? match[1] : ''
} catch {
return ''
}
}
async function canAccessPreviewUrl(url) {
const subdomain = previewSubdomainFromUrl(url)
if (!subdomain) return false
try {
const access = await fetch(
`/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`,
{ credentials: 'same-origin' }
).then((r) => r.json())
return Boolean(access.allowed)
} catch {
return false
}
}
async function resolvePostLoginTarget() {
const next = safeRedirectUrl(getQueryParam('next'))
if (next && (await canAccessPreviewUrl(next))) {
return next
}
return '/dashboard.html'
}
async function postLoginRedirect() {
window.location.href = await resolvePostLoginTarget()
}
/* -------------------------------------------------------------- Login-Page --- */
async function initLoginPage() {
const form = document.getElementById('login-form')
const errorEl = document.getElementById('login-error')
const btn = document.getElementById('login-btn')
const errorFromQuery = getQueryParam('error')
if (errorFromQuery) {
showError(errorEl, errorFromQuery)
}
async function applyCooldown(seconds) {
if (seconds <= 0) return
showError(
errorEl,
`Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).`
)
btn.disabled = true
const tick = setInterval(async () => {
const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({}))
const left = status.retryAfterSeconds || 0
if (left <= 0) {
clearInterval(tick)
hideError(errorEl)
btn.disabled = false
return
}
showError(
errorEl,
`Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).`
)
}, 1000)
}
try {
const status = await fetch('/api/auth/login-status').then((r) => r.json())
if (status.blocked) {
await applyCooldown(status.retryAfterSeconds)
}
} catch {
/* ignore */
}
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
}
} catch {
/* not logged in */
}
form?.addEventListener('submit', async (e) => {
e.preventDefault()
if (btn.disabled) return
errorEl.classList.add('hidden')
btn.disabled = true
btn.classList.add('is-loading')
btn.setAttribute('aria-busy', 'true')
let cooldownActive = false
try {
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) {
showError(errorEl, err.message)
if (err.status === 429) {
cooldownActive = true
await applyCooldown(err.retryAfterSeconds || 900)
}
} finally {
btn.classList.remove('is-loading')
btn.removeAttribute('aria-busy')
if (!cooldownActive) btn.disabled = false
}
})
}
/* --------------------------------------------------------------- View-as --- */
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'
})
}
/* ------------------------------------------------------------- Rechnungen --- */
async function loadCustomerInvoices() {
const body = document.getElementById('invoices-body')
if (!body) return
try {
const data = await api('/api/invoices')
if (!data.linked) {
body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
return
}
if (!data.invoices.length) {
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
return
}
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><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td style="white-space:nowrap">
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a> · ` : ''}
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
</td>
</tr>`).join('')}
</tbody>
</table>`
} catch (err) {
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
}
/* -------------------------------------------------------------- Aktivität --- */
async function loadCustomerActivity() {
const body = document.getElementById('activity-body')
if (!body) return
try {
const { tickets } = await api('/api/activity')
if (!tickets || !tickets.length) {
body.innerHTML = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
return
}
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 style="margin: 6px 0 0 14px;">
${w.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(w.serviceType || 'Arbeit')}</span>`}
${w.employeeName ? ` <strong>${escapeHtml(w.employeeName)}</strong>` : ''}
<span class="muted"> · ${fmtDateShort(w.date)}</span>
${Number(w.totalTime) ? `<span class="pill pill-ok">${fmtMinutes(w.totalTime)}</span>` : ''}
${w.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
${w.details ? `<div class="activity-ws">${escapeHtml(w.details)}</div>` : ''}
</div>
`).join('')}
</div>`).join('')
} catch (err) {
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
}
/* ------------------------------------------------------------------ Chat --- */
const chatState = {
lastCreatedAt: '',
pollTimer: null,
badgeTimer: null,
initialized: false,
viewAs: false,
}
function appendChatMessages(messages) {
const box = document.getElementById('chat-messages')
if (!box || !messages.length) return
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'customer' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
}
function updateChatBadge(unread) {
const badge = document.getElementById('chat-badge')
if (!badge) return
if (unread > 0) {
badge.textContent = unread
badge.classList.remove('hidden')
} else {
badge.classList.add('hidden')
}
}
async function pollChatMessages() {
if (document.hidden) return
try {
const q = chatState.lastCreatedAt ? `?since=${encodeURIComponent(chatState.lastCreatedAt)}` : ''
const data = await api(`/api/chat/messages${q}`)
appendChatMessages(data.messages || [])
if (isChatTabActive() && (data.unread || 0) > 0) {
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
updateChatBadge(0)
}
} catch {
/* naechster Poll versucht es erneut */
}
}
function isChatTabActive() {
return document.querySelector('.portal-tab[data-tab="chat"]')?.classList.contains('active')
}
async function sendChatMessage() {
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
const text = input.value.trim()
if (!text) return
btn.disabled = true
try {
const { message } = await api('/api/chat/messages', {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendChatMessages([message])
} catch (err) {
alert(err.status === 403 ? err.message : 'Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function initChat() {
if (chatState.initialized) return
chatState.initialized = true
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
if (chatState.viewAs) {
input.disabled = true
btn.disabled = true
input.placeholder = 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.'
} else {
btn.addEventListener('click', sendChatMessage)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendChatMessage()
}
})
}
await pollChatMessages()
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
updateChatBadge(0)
chatState.pollTimer = setInterval(() => {
if (isChatTabActive()) pollChatMessages()
}, 10000)
}
async function pollChatBadge() {
if (document.hidden || isChatTabActive()) return
try {
const { unread } = await api('/api/chat/unread-count')
updateChatBadge(unread)
} catch {
/* ignore */
}
}
/* -------------------------------------------------------------- Dashboard --- */
async function initDashboardPage() {
const meta = document.getElementById('customer-meta')
const list = document.getElementById('projects')
const loading = document.getElementById('loading')
const empty = document.getElementById('empty')
const loadError = document.getElementById('load-error')
const logoutBtn = document.getElementById('logout-btn')
logoutBtn?.addEventListener('click', async () => {
await api('/api/auth/logout', { method: 'POST' })
window.location.href = '/login.html'
})
// Tabs (Muster wie Admin-Dashboard)
const loadedTabs = new Set(['projects'])
const lazyLoaders = {
activity: loadCustomerActivity,
invoices: loadCustomerInvoices,
chat: initChat,
}
document.querySelectorAll('.portal-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.portal-tab').forEach((t) => t.classList.remove('active'))
document.querySelectorAll('.portal-section').forEach((s) => s.classList.remove('active'))
tab.classList.add('active')
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
const name = tab.dataset.tab
if (!loadedTabs.has(name) && lazyLoaders[name]) {
loadedTabs.add(name)
lazyLoaders[name]()
}
if (name === 'chat') {
api('/api/chat/read', { method: 'POST' }).catch(() => {})
updateChatBadge(0)
}
})
})
try {
const me = await api('/api/auth/me')
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
}
const customer = me.customer
chatState.viewAs = Boolean(me.viewAs)
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) {
list.classList.remove('hidden')
list.innerHTML = ''
for (const project of projects) {
list.appendChild(renderProjectCard(project, features))
}
} else {
empty.classList.remove('hidden')
}
// Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist)
pollChatBadge()
chatState.badgeTimer = setInterval(pollChatBadge, 45000)
} catch (err) {
loading.classList.add('hidden')
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
window.location.href = '/login.html'
return
}
showError(loadError, err.message)
}
}
window.initLoginPage = initLoginPage
window.initDashboardPage = initDashboardPage