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>
This commit is contained in:
2026-07-11 18:01:24 +00:00
parent 177ee6a13d
commit d4bd510dcd
23 changed files with 1510 additions and 100 deletions

View File

@@ -28,34 +28,8 @@ function hideError(el) {
if (el) el.classList.add('hidden')
}
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>`
: '<p class="muted">Keine zusätzlichen Features freigeschaltet.</p>'
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>
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '')}</dd>
</dl>
${featureHtml}
`
return li
}
function escapeHtml(str) {
return String(str)
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -70,6 +44,161 @@ 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)
@@ -116,17 +245,6 @@ async function resolvePostLoginTarget() {
if (next && (await canAccessPreviewUrl(next))) {
return next
}
try {
const { projects } = await api('/api/projects')
const readyProjects = (projects || []).filter(isProjectPreviewReady)
if (readyProjects.length === 1) {
return readyProjects[0].previewUrl
}
} catch {
/* dashboard fallback */
}
return '/dashboard.html'
}
@@ -134,6 +252,7 @@ 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')
@@ -226,17 +345,7 @@ 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' })
}
/* --------------------------------------------------------------- View-as --- */
function renderViewAsBanner(me) {
const banner = document.getElementById('viewas-banner')
if (!banner || !me.viewAs) return
@@ -251,14 +360,20 @@ function renderViewAsBanner(me) {
})
}
/* ------------------------------------------------------------- Rechnungen --- */
async function loadCustomerInvoices() {
const section = document.getElementById('invoices-section')
const body = document.getElementById('invoices-body')
if (!section || !body) return
if (!body) return
try {
const data = await api('/api/invoices')
if (!data.linked || !data.invoices.length) return
section.classList.remove('hidden')
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>
@@ -269,24 +384,29 @@ async function loadCustomerInvoices() {
<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>
<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 {
/* Rechnungen sind optional */
} catch (err) {
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
}
/* -------------------------------------------------------------- Aktivität --- */
async function loadCustomerActivity() {
const section = document.getElementById('activity-section')
const body = document.getElementById('activity-body')
if (!section || !body) return
if (!body) return
try {
const { tickets } = await api('/api/activity')
if (!tickets || !tickets.length) return
section.classList.remove('hidden')
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>
@@ -295,14 +415,134 @@ async function loadCustomerActivity() {
<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>
<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 {
/* Aktivität ist optional */
} 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')
@@ -316,6 +556,31 @@ async function initDashboardPage() {
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')
@@ -333,6 +598,7 @@ async function initDashboardPage() {
}
const customer = me.customer
chatState.viewAs = Boolean(me.viewAs)
renderViewAsBanner(me)
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
@@ -355,8 +621,9 @@ async function initDashboardPage() {
empty.classList.remove('hidden')
}
loadCustomerInvoices()
loadCustomerActivity()
// 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')) {