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:
148
public/admin.js
148
public/admin.js
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user