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:
@@ -49,6 +49,33 @@
|
||||
.recent-list li { padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,0.07); font-size: 14px; }
|
||||
.recent-list li:last-child { border-bottom: none; }
|
||||
.two-col { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
|
||||
/* Chat */
|
||||
.badge-pill {
|
||||
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
||||
align-items: center; justify-content: center; border-radius: 999px;
|
||||
background: #f87171; color: #fff; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 14px; min-height: 480px; }
|
||||
@media (max-width: 760px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
.chat-list { border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow-y: auto; max-height: 560px; }
|
||||
.chat-list-item { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
|
||||
.chat-list-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.chat-list-item.active { background: rgba(94,234,212,0.1); }
|
||||
.chat-list-item .last { font-size: 12px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-thread { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 4px; max-height: 440px; }
|
||||
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
|
||||
.chat-bubble.mine { align-self: flex-end; background: rgba(94,234,212,0.18); border: 1px solid rgba(94,234,212,0.45); border-bottom-right-radius: 4px; }
|
||||
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
|
||||
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
|
||||
.chat-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 44px; max-height: 120px; padding: 10px 12px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
|
||||
}
|
||||
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
@@ -73,6 +100,7 @@
|
||||
<button class="admin-tab active" data-tab="overview">Übersicht</button>
|
||||
<button class="admin-tab" data-tab="projects">Projekte</button>
|
||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
@@ -119,6 +147,18 @@
|
||||
</div>
|
||||
<p class="admin-note">Kundennummer direkt im Feld ändern und mit ✓ speichern. „Als Kunde ansehen“ zeigt dir das Portal genau so, wie der Kunde es sieht.</p>
|
||||
</section>
|
||||
|
||||
<!-- Chat -->
|
||||
<section class="admin-section" id="tab-chat">
|
||||
<div class="chat-layout">
|
||||
<div class="chat-list" id="admin-chat-list">
|
||||
<div class="chat-list-item"><span class="muted">Lade Unterhaltungen…</span></div>
|
||||
</div>
|
||||
<div class="chat-thread" id="admin-chat-thread">
|
||||
<div class="chat-empty">Unterhaltung links auswählen</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
|
||||
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
|
||||
|
||||
399
public/app.js
399
public/app.js
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -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')) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Meine Projekte – Webklar Kundenbereich</title>
|
||||
<title>Kundenbereich – Webklar</title>
|
||||
<link rel="stylesheet" href="/login.css">
|
||||
<style>
|
||||
.viewas-banner {
|
||||
@@ -11,20 +11,75 @@
|
||||
padding: 10px 16px; margin-bottom: 16px; border-radius: 12px;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45);
|
||||
}
|
||||
|
||||
/* Tabs (Muster aus Admin, violett) */
|
||||
.portal-tabs { display: flex; gap: 8px; margin: 14px 0 22px; flex-wrap: wrap; }
|
||||
.portal-tab {
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: inherit; padding: 9px 18px; border-radius: 999px; cursor: pointer; font: inherit;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.portal-tab.active { background: rgba(157,61,254,0.22); border-color: rgba(157,61,254,0.65); }
|
||||
.portal-section { display: none; }
|
||||
.portal-section.active { display: block; }
|
||||
.portal-section-title { margin: 0 0 14px; font-size: 20px; }
|
||||
|
||||
.badge-pill {
|
||||
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
|
||||
align-items: center; justify-content: center; border-radius: 999px;
|
||||
background: #9d3dfe; color: #fff; font-size: 12px; font-weight: 700;
|
||||
}
|
||||
|
||||
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.simple-table th, .simple-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.simple-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
|
||||
.simple-table tr:last-child td { border-bottom: none; }
|
||||
.btn-link { color: #7dd3fc; text-decoration: none; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
|
||||
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; white-space: nowrap; }
|
||||
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
|
||||
.pill-warn { background: rgba(157,61,254,0.18); border-color: rgba(157,61,254,0.5); }
|
||||
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
|
||||
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.85; }
|
||||
.pill-git { background: rgba(251,146,60,0.16); border-color: rgba(251,146,60,0.5); }
|
||||
|
||||
.activity-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.activity-item:last-child { border-bottom: none; }
|
||||
.activity-status {
|
||||
display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
|
||||
}
|
||||
.activity-ws { font-size: 13px; opacity: 0.8; margin: 3px 0 3px 14px; }
|
||||
.portal-section-title { margin: 28px 0 12px; font-size: 20px; }
|
||||
.activity-ws { font-size: 13px; opacity: 0.85; margin: 4px 0 4px 14px; white-space: pre-wrap; }
|
||||
|
||||
/* Projekt-Details */
|
||||
.project-details-toggle { margin-top: 10px; }
|
||||
.project-details { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; }
|
||||
.project-details h3 { margin: 12px 0 8px; font-size: 15px; }
|
||||
.commit-item { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; }
|
||||
.commit-item:last-child { border-bottom: none; }
|
||||
.commit-meta { font-size: 12px; opacity: 0.6; }
|
||||
.commit-body { font-size: 13px; opacity: 0.8; white-space: pre-wrap; margin: 2px 0 0 8px; }
|
||||
|
||||
/* Chat */
|
||||
.chat-box { display: flex; flex-direction: column; height: 480px; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 6px 4px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
|
||||
.chat-bubble.mine { align-self: flex-end; background: rgba(157,61,254,0.3); border: 1px solid rgba(157,61,254,0.5); border-bottom-right-radius: 4px; }
|
||||
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
|
||||
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
|
||||
.chat-input-row textarea {
|
||||
flex: 1; resize: none; min-height: 46px; max-height: 130px; padding: 12px 14px;
|
||||
border-radius: 14px; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
|
||||
}
|
||||
.chat-send {
|
||||
background: #9d3dfe; border: none; color: #fff; font: inherit; font-weight: 600;
|
||||
padding: 12px 20px; border-radius: 14px; cursor: pointer;
|
||||
}
|
||||
.chat-send:disabled { opacity: 0.5; cursor: default; }
|
||||
.chat-hint { font-size: 12px; opacity: 0.55; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
@@ -44,21 +99,46 @@
|
||||
<main class="portal-main dashboard-content-reveal">
|
||||
<div id="viewas-banner" class="viewas-banner hidden"></div>
|
||||
|
||||
<h1 class="portal-title">Meine Website-Projekte</h1>
|
||||
|
||||
<h1 class="portal-title">Kundenbereich</h1>
|
||||
<p id="load-error" class="error hidden"></p>
|
||||
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
|
||||
<ul id="projects" class="projects hidden"></ul>
|
||||
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
|
||||
|
||||
<section id="invoices-section" class="hidden">
|
||||
<h2 class="portal-section-title">Meine Rechnungen</h2>
|
||||
<div class="portal-card" id="invoices-body"></div>
|
||||
<div class="portal-tabs">
|
||||
<button class="portal-tab active" data-tab="projects">Projekte</button>
|
||||
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button>
|
||||
<button class="portal-tab" data-tab="invoices">Rechnungen</button>
|
||||
<button class="portal-tab" data-tab="chat">Chat <span id="chat-badge" class="badge-pill hidden"></span></button>
|
||||
</div>
|
||||
|
||||
<!-- Projekte -->
|
||||
<section class="portal-section active" id="tab-projects">
|
||||
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
|
||||
<ul id="projects" class="projects hidden"></ul>
|
||||
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
|
||||
</section>
|
||||
|
||||
<section id="activity-section" class="hidden">
|
||||
<!-- Was wurde gemacht -->
|
||||
<section class="portal-section" id="tab-activity">
|
||||
<h2 class="portal-section-title">Was wurde gemacht</h2>
|
||||
<div class="portal-card" id="activity-body"></div>
|
||||
<div class="portal-card" id="activity-body"><p class="muted">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="portal-section" id="tab-invoices">
|
||||
<h2 class="portal-section-title">Meine Rechnungen</h2>
|
||||
<div class="portal-card" id="invoices-body"><p class="muted">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Chat -->
|
||||
<section class="portal-section" id="tab-chat">
|
||||
<h2 class="portal-section-title">Chat mit dem Webklar-Team</h2>
|
||||
<div class="portal-card chat-box">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div class="chat-input-row">
|
||||
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das Webklar-Team…"></textarea>
|
||||
<button type="button" class="chat-send" id="chat-send">Senden</button>
|
||||
</div>
|
||||
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user