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

@@ -249,6 +249,145 @@ async function loadCustomers(search = '') {
}
}
/* ----------------------------------------------------------------- Chat --- */
const adminChat = {
activeCustomerId: '',
activeCustomerName: '',
lastCreatedAt: '',
threadTimer: null,
}
function updateAdminChatBadge(unread) {
const badge = document.getElementById('admin-chat-badge')
if (!badge) return
if (unread > 0) {
badge.textContent = unread
badge.classList.remove('hidden')
} else {
badge.classList.add('hidden')
}
}
async function pollAdminChatBadge() {
if (document.hidden) return
try {
const { unread } = await api('/api/portal-admin/chats/unread-count')
updateAdminChatBadge(unread)
} catch {
/* ignore */
}
}
async function loadChatList() {
const list = document.getElementById('admin-chat-list')
try {
const { chats } = await api('/api/portal-admin/chats')
if (!chats.length) {
list.innerHTML = '<div class="chat-list-item"><span class="muted">Noch keine Unterhaltungen.</span></div>'
return
}
list.innerHTML = chats.map((c) => `
<div class="chat-list-item${c.customerId === adminChat.activeCustomerId ? ' active' : ''}" data-chat-customer="${escapeHtml(c.customerId)}" data-chat-name="${escapeHtml(c.customerName)}">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<strong>${escapeHtml(c.customerName)}</strong>
${c.unreadFromCustomer ? `<span class="badge-pill">${c.unreadFromCustomer}</span>` : ''}
</div>
<div class="last">${c.lastSender === 'admin' ? 'Du: ' : ''}${escapeHtml(c.lastText)}</div>
<div class="last">${fmtDateTime(c.lastAt)}</div>
</div>`).join('')
list.querySelectorAll('[data-chat-customer]').forEach((el) =>
el.addEventListener('click', () => openChatThread(el.dataset.chatCustomer, el.dataset.chatName)))
} catch (err) {
list.innerHTML = `<div class="chat-list-item"><span class="error">${escapeHtml(err.message)}</span></div>`
}
}
function appendAdminChatMessages(messages) {
const box = document.getElementById('admin-chat-messages')
if (!box || !messages.length) return
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
}
async function pollAdminThread() {
if (!adminChat.activeCustomerId || document.hidden) return
try {
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`)
if (messages.length) {
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
}
} catch {
/* naechster Poll */
}
}
async function sendAdminChatMessage() {
const input = document.getElementById('admin-chat-input')
const btn = document.getElementById('admin-chat-send')
const text = input.value.trim()
if (!text || !adminChat.activeCustomerId) return
btn.disabled = true
try {
const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendAdminChatMessages([message])
loadChatList()
} catch (err) {
alert('Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function openChatThread(customerId, customerName) {
adminChat.activeCustomerId = customerId
adminChat.activeCustomerName = customerName || 'Kunde'
adminChat.lastCreatedAt = ''
const thread = document.getElementById('admin-chat-thread')
thread.innerHTML = `
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
<div class="chat-messages" id="admin-chat-messages"></div>
<div class="chat-input-row">
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
</div>`
document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage)
document.getElementById('admin-chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendAdminChatMessage()
}
})
try {
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
updateAdminChatBadge(0)
pollAdminChatBadge()
loadChatList()
} catch (err) {
document.getElementById('admin-chat-messages').innerHTML = `<span class="error">${escapeHtml(err.message)}</span>`
}
clearInterval(adminChat.threadTimer)
adminChat.threadTimer = setInterval(pollAdminThread, 15000)
}
/* ----------------------------------------------------------------- Init --- */
async function initAdminPage() {
document.getElementById('logout-btn')?.addEventListener('click', async () => {
@@ -273,12 +412,17 @@ async function initAdminPage() {
return
}
let chatListLoaded = false
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')
if (tab.dataset.tab === 'chat' && !chatListLoaded) {
chatListLoaded = true
loadChatList()
}
})
})
@@ -294,6 +438,10 @@ async function initAdminPage() {
loadOverview()
loadProjects()
loadCustomers()
// Chat-Badge global pollen
pollAdminChatBadge()
setInterval(pollAdminChatBadge, 60000)
}
window.initAdminPage = initAdminPage