Projektdetails und Fortschritt kundenverständlich

Der Server bereitet Commits, Tickets und Arbeitseinträge für Kunden auf
(server/services/customerProjectView.js), die Rohdaten bleiben intern:
- keine Co-Authored-By-/Signed-off-by-Zeilen und KI-Hinweise, keine
  Commit-Kürzel, Kontonamen, Git-Links oder Branch
- technische Commits (Preview-Config, Merges) ausgeblendet, bekannte
  Automatik-Texte übersetzt, "fix"/"upload" -> "Website aktualisiert"
- Ticketstatus und Arbeitsarten auf Deutsch (Open -> Eingegangen,
  Remote -> Online erledigt, ...), Fortschritt in 4 Schritten
- Akquise-Tickets auch in den Projektdetails verborgen
- /api/projects ohne Gitea-URL, Repo- und Vorlagennamen

Darstellung: Auftragskarte mit Status und Fortschrittsleiste, Zeitleiste
"Letzte Änderungen" nach Tagen, Arbeiten mit Farbe/Symbol je Art und
Arbeitszeit; der Tab "Fortschritt" nutzt dieselben Bausteine. In der
App-Ansicht öffnen sich die Projektdetails als eigene Seite mit Zurück
zu "Projekte".

Tests: npm test (node --test, 22 Fälle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 18:46:22 +00:00
1 parent e54f97144c
commit ba9916cfe6
9 files changed
+932 -138

No files matched your search

+224 -79
View File
@@ -75,7 +75,8 @@ function fmtMinutes(min) {
if (!n) return ''
const h = Math.floor(n / 60)
const m = n % 60
return h ? `${h} h ${m} min` : `${m} min`
if (!h) return `${m} min`
return m ? `${h} h ${m} min` : `${h} h`
}
/* -------------------------------------------------------------- Projekte --- */
@@ -162,7 +163,10 @@ function renderProjectCard(project, features) {
<button type="button" class="kb-chip" data-view="invoices">Rechnungen</button>
${extraChips.map((label) => `<span class="kb-chip">${escapeHtml(label)}</span>`).join('')}
</div>
<button type="button" class="kb-btn-outline" data-details-btn>Projektdetails anzeigen</button>
<button type="button" class="kb-btn-outline kb-details-btn" data-details-btn aria-expanded="false">
<span data-details-label>Projektdetails anzeigen</span>
<svg class="kb-details-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<div class="kb-details hidden" data-details-box></div>
</div>
`
@@ -170,44 +174,178 @@ function renderProjectCard(project, features) {
const frame = li.querySelector('[data-preview-frame]')
if (frame) setupPreviewFrame(frame)
// Am Desktop klappen die Details in der Karte auf; in der App-Ansicht (Handy) oeffnen sie sich
// als eigene Seite mit Zurueck-Pfeil (openProjectPage)
const btn = li.querySelector('[data-details-btn]')
const label = li.querySelector('[data-details-label]')
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 ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen'
if (isHidden && !loaded) {
btn.addEventListener('click', () => {
if (isAppLayout()) {
openProjectPage(project)
return
}
const opening = box.classList.contains('hidden')
box.classList.toggle('hidden', !opening)
btn.setAttribute('aria-expanded', String(opening))
label.textContent = opening ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen'
if (opening && !loaded) {
loaded = true
await loadProjectDetails(project, box)
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>`
/* --------------------------------------------------------- Projektdetails --- */
// Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine
// Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch.
// Dieselben Bausteine nutzt der Tab "Fortschritt" (loadCustomerActivity).
const PD_ICONS = {
update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>',
online: '<rect x="4" y="5" width="16" height="11" rx="2"/><path d="M2 19h20"/>',
onsite: '<path d="M12 21s7-6.2 7-11.5a7 7 0 0 0-14 0C5 14.8 12 21 12 21z"/><circle cx="12" cy="9.5" r="2.5"/>',
office: '<path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 10h2a2 2 0 0 1 2 2v9M9 7h3M9 11h3M9 15h3M3 21h18"/>',
work: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
changes: '<path d="M3 12a9 9 0 1 0 2.6-6.4L3 8"/><path d="M3 3v5h5M12 7.5V12l3 2"/>',
files: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
}
const ORDER_STEPS = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']
const PD_CHANGES_SHOWN = 5
const PD_ENTRIES_SHOWN = 4
function pdIcon(name, cls = 'pd-ico') {
return `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${PD_ICONS[name] || PD_ICONS.work}</svg>`
}
// "2026-09-20" -> "20.09.2026" (reines Datum, ohne Zeitzonen-Umrechnung)
function fmtIsoDay(day) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(day || ''))
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
}
// Ueberschrift je Tag in der Zeitleiste: "Heute", "Gestern", sonst "Mittwoch, 24. September"
function changeDayLabel(date) {
const today = new Date()
const dayStart = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
const diff = Math.round((dayStart(today) - dayStart(date)) / 86400000)
if (diff === 0) return 'Heute'
if (diff === 1) return 'Gestern'
return date.toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', ...(date.getFullYear() === today.getFullYear() ? {} : { year: 'numeric' }),
})
}
// Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt
function renderOrder(order) {
const { status } = order
const steps = status.step > 0
? `<ol class="pd-steps" aria-label="Fortschritt des Auftrags">${ORDER_STEPS.map((label, i) => {
const n = i + 1
const state = n < status.step ? 'is-done' : n === status.step ? 'is-current' : ''
return `<li class="${state}"${n === status.step ? ' aria-current="step"' : ''}>${label}</li>`
}).join('')}</ol>`
: ''
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('')
const hint = status.tone === 'waiting'
? '<p class="pd-order-hint">Wir brauchen noch eine Info von dir. <button type="button" class="kb-link" data-view="chat">Im Chat antworten</button></p>'
: ''
return `
<div class="pd-order-head">
<div class="pd-order-name">
${order.number ? `<span class="pd-order-no">Auftrag ${escapeHtml(order.number)}</span>` : ''}
<h3>${escapeHtml(order.topic || 'Dein Auftrag')}</h3>
</div>
<span class="pd-status">${escapeHtml(status.label)}</span>
</div>
${steps}${hint}`
}
// Lange Listen: nur die ersten Eintraege zeigen, "3 weitere … anzeigen" klappt den Rest auf (initPdToggles)
function pdMoreButton(total, shown, [one, many]) {
const rest = total - shown
if (rest <= 0) return ''
const text = `${rest} ${rest === 1 ? one : many} anzeigen`
return `<button type="button" class="pd-more" data-pd-more data-more-label="${escapeAttr(text)}">${escapeHtml(text)}</button>`
}
function renderChange(c, extra) {
const d = new Date(c.date)
const time = Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return `
<li class="pd-change"${extra ? ' data-extra' : ''}>
<div class="pd-change-head">
<strong>${escapeHtml(c.title)}</strong>
${time ? `<time datetime="${escapeAttr(c.date)}">${time}</time>` : ''}
</div>
${c.details?.length ? `<ul class="pd-change-details">${c.details.map((t) => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : ''}
</li>`
}
// Zeitleiste der letzten Aenderungen, nach Tagen gruppiert
function renderChanges(changes) {
if (!changes.length) return '<p class="pd-empty">Noch keine Änderungen an der Website.</p>'
const groups = []
changes.forEach((c, i) => {
const d = new Date(c.date)
const day = Number.isNaN(d.getTime()) ? '' : changeDayLabel(d)
if (!groups.length || groups[groups.length - 1].day !== day) groups.push({ day, first: i, items: [] })
groups[groups.length - 1].items.push(renderChange(c, i >= PD_CHANGES_SHOWN))
})
return `
<div class="pd-collapsible">
<div class="pd-timeline">
${groups.map((g) => `
<div class="pd-day"${g.first >= PD_CHANGES_SHOWN ? ' data-extra' : ''}>
${g.day ? `<p class="pd-day-label">${escapeHtml(g.day)}</p>` : ''}
<ul class="pd-changes">${g.items.join('')}</ul>
</div>`).join('')}
</div>
${pdMoreButton(changes.length, PD_CHANGES_SHOWN, ['weitere Änderung', 'weitere Änderungen'])}
</div>`
}
function renderWorkEntry(e, extra) {
const meta = [fmtIsoDay(e.date), e.person].filter(Boolean).map(escapeHtml).join(' · ')
const body = e.changes?.length
? `<ul class="pd-entry-changes">${e.changes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')}</ul>`
: e.text ? `<p class="pd-entry-text">${escapeHtml(e.text)}</p>` : ''
return `
<li class="pd-entry pd-kind-${escapeAttr(e.kind)}"${extra ? ' data-extra' : ''}>
<span class="pd-entry-ico">${pdIcon(e.kind)}</span>
<div class="pd-entry-main">
<div class="pd-entry-head">
<strong>${escapeHtml(e.label)}</strong>
${meta ? `<span class="pd-entry-meta">${meta}</span>` : ''}
</div>
${body}
</div>
${e.minutes ? `<span class="pd-time" title="Arbeitszeit">${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}</span>` : ''}
</li>`
}
function renderWorkEntries(entries, emptyText) {
if (!entries.length) return `<p class="pd-empty">${emptyText}</p>`
return `
<div class="pd-collapsible">
<ul class="pd-work">${entries.map((e, i) => renderWorkEntry(e, i >= PD_ENTRIES_SHOWN)).join('')}</ul>
${pdMoreButton(entries.length, PD_ENTRIES_SHOWN, ['weiteren Eintrag', 'weitere Einträge'])}
</div>`
}
function initPdToggles(root) {
root.querySelectorAll('[data-pd-more]').forEach((btn) => btn.addEventListener('click', () => {
const wrap = btn.closest('.pd-collapsible')
const open = wrap.classList.toggle('is-open')
btn.textContent = open ? 'Weniger anzeigen' : btn.dataset.moreLabel
}))
}
function renderFileOverview(files) {
if (!files || !files.totalFiles) {
return '<p class="muted">Keine Datei-Informationen verfügbar.</p>'
return '<p class="pd-empty">Keine Datei-Informationen verfügbar.</p>'
}
const rows = (files.topLevel || []).map((entry) => `
<tr>
@@ -216,50 +354,53 @@ function renderFileOverview(files) {
<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">
<p class="pd-note">${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}</p>
<div class="kb-table-wrap"><table class="simple-table pd-files">
<thead><tr><th>Ordner / Datei</th><th>Dateien</th><th>Größe</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
</table></div>`
}
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('')
function pdSection(icon, title, body) {
return `<section class="pd-section"><h3 class="pd-title">${pdIcon(icon)}${title}</h3>${body}</section>`
}
async function loadProjectDetails(project, box) {
box.innerHTML = '<p class="muted">Lade Details…</p>'
// stillWanted: die Handy-Seite teilt sich einen Container fuer alle Projekte; eine spaete Antwort fuer
// ein inzwischen verlassenes Projekt darf die Seite des naechsten nicht ueberschreiben
async function loadProjectDetails(project, box, { stillWanted = () => true } = {}) {
box.innerHTML = '<p class="pd-empty">Details werden geladen…</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),
])
if (!stillWanted()) return
const order = work?.order
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>'}
`
${order ? `<section class="pd-order pd-tone-${escapeAttr(order.status.tone)}">${renderOrder(order)}</section>` : ''}
${pdSection('changes', 'Letzte Änderungen', git
? renderChanges(git.changes || [])
: '<p class="pd-empty">Die Änderungen können gerade nicht geladen werden.</p>')}
${order || !work ? pdSection('work', 'Arbeiten am Projekt', work
? renderWorkEntries(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.')
: '<p class="pd-empty">Die Arbeiten können gerade nicht geladen werden.</p>') : ''}
${pdSection('files', 'Dateien', files ? renderFileOverview(files) : '<p class="pd-empty">Die Datei-Übersicht kann gerade nicht geladen werden.</p>')}`
initPdToggles(box)
}
// App-Ansicht: Projektdetails als Unterseite von "Projekte" (Zurueck-Pfeil, eigener Verlaufseintrag).
// Dasselbe Projekt erneut oeffnen zeigt die schon geladenen Details.
const projectPage = { project: null }
function openProjectPage(project) {
const view = document.getElementById('view-projekt')
if (!view) return
const reload = projectPage.project !== project
projectPage.project = project
view.querySelector('.kb-head h1').textContent = project.projectName || project.subdomain || 'Projekt'
showView('projekt')
if (reload) loadProjectDetails(project, view.querySelector('.kb-details'), { stillWanted: () => projectPage.project === project })
}
/* -------------------------------------------------------- Login-Redirects --- */
@@ -479,6 +620,8 @@ async function loadCustomerInvoices() {
}
/* -------------------------------------------------------------- Aktivität --- */
// Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten
// (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server)
async function loadCustomerActivity() {
const body = document.getElementById('activity-body')
if (!body) return
@@ -489,23 +632,11 @@ async function loadCustomerActivity() {
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('')
<article class="pd-order pd-order-card pd-tone-${escapeAttr(t.status.tone)}">
${renderOrder(t)}
${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')}
</article>`).join('')
initPdToggles(body)
} catch (err) {
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
@@ -1143,7 +1274,8 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift' }
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift', projekt: 'projekt' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
const loadedViews = new Set()
@@ -1291,8 +1423,9 @@ async function initDashboardPage() {
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert
const APP_PARENT = { abo: 'wartung', takeover: 'wartung' }
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil)
const APP_PARENT = { abo: 'wartung', takeover: 'wartung', projekt: 'projects' }
const APP_PARENT_LABEL = { wartung: 'Wartung', projects: 'Projekte' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
@@ -1339,7 +1472,13 @@ function updateBarState() {
function updateAppBar() {
const sub = isAppLayout() && Boolean(APP_PARENT[appShell.view])
document.getElementById('kb-back')?.classList.toggle('hidden', !sub)
const back = document.getElementById('kb-back')
back?.classList.toggle('hidden', !sub)
if (back && sub) {
const label = APP_PARENT_LABEL[APP_PARENT[appShell.view]] || 'Zurück'
back.querySelector('span').textContent = label
back.setAttribute('aria-label', `Zurück zu ${label}`)
}
document.body.classList.toggle('kb-subpage', sub)
const title = document.getElementById('kb-app-title')
if (title) title.textContent = currentViewTitle()?.textContent.trim() || ''
@@ -1352,6 +1491,11 @@ function onAppViewShown(name) {
showView('abo', { updateHash: !appShell.fromHistory })
return
}
// Projektdetails als Seite nur in der App-Ansicht und nur mit gewaehltem Projekt (nicht per Direktlink)
if (name === 'projekt' && (!isAppLayout() || !projectPage.project)) {
showView('projects', { updateHash: !appShell.fromHistory })
return
}
const prev = appShell.view
const parent = APP_PARENT[name]
const nav = name === prev ? 'same' : parent === prev ? 'push' : APP_PARENT[prev] === name ? 'back' : 'tab'
@@ -1368,9 +1512,9 @@ function onAppViewShown(name) {
})
if (isAppLayout()) {
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zur Wartung
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zum Tab darüber (Wartung bzw. Projekte)
if (nav === 'push' && !appShell.fromHistory) {
window.history.replaceState(null, '', `#${VIEW_HASHES[parent]}`)
window.history.replaceState(null, '', VIEW_HASHES[parent] ? `#${VIEW_HASHES[parent]}` : window.location.pathname)
window.history.pushState({ kbSub: name }, '', `#${VIEW_HASHES[name]}`)
}
// Tabs merken sich ihre Scrollposition, Unterseiten beginnen oben, erneutes Tippen scrollt nach oben
@@ -1671,6 +1815,7 @@ function initAppShell() {
onMediaChange(appLayout, () => {
placeSideCards()
if (!isAppLayout() && appShell.view === 'wartung') showView('abo')
if (!isAppLayout() && appShell.view === 'projekt') showView('projects')
updateAppBar()
renderInstallOffers()
})
+11 -1
View File
@@ -30,7 +30,7 @@
<div class="kb-glow" aria-hidden="true"></div>
<header class="kb-header">
<!-- App-Ansicht: Zurueck aus Unterseiten der Wartung, Seitentitel beim Scrollen (initAppShell) -->
<!-- App-Ansicht: Zurueck aus Unterseiten (Wartung, Projektdetails; Text setzt updateAppBar), Seitentitel beim Scrollen (initAppShell) -->
<button type="button" class="kb-back hidden" id="kb-back" aria-label="Zurück zu Wartung">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
<span>Wartung</span>
@@ -137,6 +137,16 @@
<div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div>
</section>
<!-- App-Ansicht: Projektdetails als eigene Seite mit Zurueck zu "Projekte" (app.js, openProjectPage);
am Desktop klappen die Details in der Projektkarte auf -->
<section class="kb-view" id="view-projekt">
<header class="kb-head">
<h1>Projekt</h1>
<p>Stand des Auftrags, letzte Änderungen und Dateien.</p>
</header>
<div class="kb-details pd-page"></div>
</section>
<!-- Fortschritt -->
<section class="kb-view" id="view-activity">
<header class="kb-head">
+128 -27
View File
@@ -235,15 +235,118 @@
background: rgba(157, 61, 254, 0.14); border: 1px solid rgba(157, 61, 254, 0.4); cursor: pointer;
}
.kb-chip:hover { background: rgba(157, 61, 254, 0.24); }
.kb-details { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--kb-border); }
.kb-details h3 { margin: 16px 0 8px; font-size: 14px; letter-spacing: 0.02em; }
.kb-details h3:first-child { margin-top: 0; }
.commit-item { padding: 7px 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; color: var(--kb-faint); }
.commit-body { margin: 2px 0 0 8px; font-size: 13px; color: var(--kb-muted); white-space: pre-wrap; }
.kb-details-chev {
width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
transform: rotate(90deg); transition: transform 0.2s;
}
.kb-details-btn[aria-expanded='true'] .kb-details-chev { transform: rotate(-90deg); }
.kb-details { display: grid; gap: 24px; margin-top: 18px; padding-top: 20px; border-top: 1px solid var(--kb-border); }
/* ------------------------------------------ Tabellen, Pills, Aktivitaet --- */
/* --------------------------------------------------------- Projektdetails --- */
/* Auftrag mit Fortschrittsleiste, Zeitleiste der Aenderungen, Arbeiten mit Symbol je Art.
Dieselben Bausteine nutzt der Tab "Fortschritt" (app.js: renderOrder, renderWorkEntries). */
.pd-tone-new { --pd-c: #d8b4fe; --pd-bg: rgba(157, 61, 254, 0.1); --pd-line: rgba(157, 61, 254, 0.4); --pd-bar: #b77bff; }
.pd-tone-active { --pd-c: #e9d5ff; --pd-bg: rgba(157, 61, 254, 0.2); --pd-line: rgba(157, 61, 254, 0.58); --pd-bar: var(--kb-purple); }
.pd-tone-waiting { --pd-c: #fcd34d; --pd-bg: rgba(251, 191, 36, 0.12); --pd-line: rgba(251, 191, 36, 0.45); --pd-bar: #f5b82e; }
.pd-tone-review { --pd-c: #93c5fd; --pd-bg: rgba(59, 130, 246, 0.14); --pd-line: rgba(96, 165, 250, 0.45); --pd-bar: #4f8ff7; }
.pd-tone-done { --pd-c: #86efac; --pd-bg: rgba(74, 222, 128, 0.09); --pd-line: rgba(74, 222, 128, 0.38); --pd-bar: #34c46a; }
.pd-tone-paused, .pd-tone-stopped { --pd-c: var(--kb-muted); --pd-bg: rgba(255, 255, 255, 0.05); --pd-line: var(--kb-border-strong); --pd-bar: rgba(255, 255, 255, 0.3); }
.pd-order {
padding: 18px 20px; border-radius: 16px; border: 1px solid var(--pd-line);
background: linear-gradient(135deg, var(--pd-bg), rgba(255, 255, 255, 0.015) 72%), rgba(12, 12, 14, 0.6);
}
.pd-order-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px 14px; flex-wrap: wrap; }
.pd-order-name { min-width: 0; }
.pd-order-no { display: block; margin-bottom: 3px; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; color: var(--kb-muted); }
.pd-order-name h3 { margin: 0; font-size: 18px; line-height: 1.3; overflow-wrap: anywhere; }
.pd-status {
flex: none; display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px; border-radius: 999px;
font-size: 12.5px; font-weight: 700; color: var(--pd-c); background: var(--pd-bg); border: 1px solid var(--pd-line);
}
.pd-status::before { content: ''; flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.pd-tone-active .pd-status::before, .pd-tone-waiting .pd-status::before, .pd-tone-review .pd-status::before { animation: pd-pulse 1.8s ease-in-out infinite; }
@keyframes pd-pulse { 50% { opacity: 0.3; } }
.pd-steps { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 18px 0 0; padding: 0; list-style: none; }
.pd-steps li { position: relative; min-width: 0; padding-top: 13px; font-size: 12px; color: var(--kb-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pd-steps li::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.1); }
.pd-steps li.is-done { color: var(--kb-muted); }
.pd-steps li.is-done::before, .pd-steps li.is-current::before { background: var(--pd-bar); }
.pd-steps li.is-current { color: #fff; font-weight: 700; }
.pd-order-hint { margin: 14px 0 0; font-size: 13.5px; color: var(--pd-c); }
.pd-section { min-width: 0; }
.pd-title {
display: flex; align-items: center; gap: 9px; margin: 0 0 14px; font-size: 12px; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--kb-muted);
}
.pd-ico { flex: none; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.pd-title .pd-ico { color: var(--kb-lilac); }
.pd-empty { margin: 0; font-size: 14px; color: var(--kb-muted); }
.pd-note { margin: 0 0 8px; font-size: 13px; color: var(--kb-muted); }
/* Zeitleiste "Letzte Änderungen": Tage als Ueberschrift, jede Aenderung ein Punkt auf der Linie */
.pd-timeline { position: relative; padding-left: 22px; }
.pd-timeline::before {
content: ''; position: absolute; left: 5px; top: 8px; bottom: 8px; width: 2px; border-radius: 1px;
background: linear-gradient(var(--kb-purple-line), rgba(157, 61, 254, 0.06));
}
.pd-day + .pd-day { margin-top: 16px; }
.pd-day-label { margin: 0 0 8px; font-size: 12px; font-weight: 700; letter-spacing: 0.02em; color: var(--kb-lilac); }
.pd-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.pd-change { position: relative; }
.pd-change::before {
content: ''; position: absolute; left: -21px; top: 6px; width: 8px; height: 8px; border-radius: 50%;
background: #0c0c0e; border: 2px solid var(--kb-purple);
}
.pd-day:first-child .pd-change:first-child::before { background: var(--kb-purple); box-shadow: 0 0 0 4px rgba(157, 61, 254, 0.22); }
.pd-change-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.pd-change-head strong { font-size: 14.5px; line-height: 1.4; overflow-wrap: anywhere; }
.pd-change-head time { flex: none; font-size: 12px; color: var(--kb-faint); font-variant-numeric: tabular-nums; }
.pd-change-details { margin: 4px 0 0; padding: 0 0 0 17px; font-size: 13px; line-height: 1.5; color: var(--kb-muted); }
.pd-change-details li + li { margin-top: 3px; }
.pd-change-details li::marker { color: var(--kb-faint); }
/* Lange Listen gekuerzt; "Weitere … anzeigen" klappt auf */
.pd-collapsible:not(.is-open) [data-extra] { display: none; }
.pd-more {
margin-top: 14px; padding: 8px 15px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent;
color: #d4b4ff; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
}
.pd-more:hover { background: rgba(157, 61, 254, 0.12); }
/* Arbeiten: Farbe und Symbol je Art (Website-Update, online, vor Ort, Buero) */
.pd-kind-update { --pd-k: #d8b4fe; --pd-kbg: rgba(157, 61, 254, 0.18); }
.pd-kind-online { --pd-k: #93c5fd; --pd-kbg: rgba(59, 130, 246, 0.17); }
.pd-kind-onsite { --pd-k: #5eead4; --pd-kbg: rgba(45, 212, 191, 0.15); }
.pd-kind-office { --pd-k: #fcd34d; --pd-kbg: rgba(251, 191, 36, 0.15); }
.pd-kind-work { --pd-k: rgba(255, 255, 255, 0.78); --pd-kbg: rgba(255, 255, 255, 0.08); }
.pd-work { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.pd-entry {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: start;
padding: 12px 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.025); border: 1px solid var(--kb-border);
}
.pd-entry-ico { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: var(--pd-k); background: var(--pd-kbg); }
.pd-entry-ico .pd-ico { width: 19px; height: 19px; }
.pd-entry-main { min-width: 0; }
.pd-entry-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-height: 22px; }
.pd-entry-head strong { font-size: 14.5px; color: var(--pd-k); }
.pd-entry-meta { font-size: 12.5px; color: var(--kb-faint); }
.pd-entry-text { margin: 4px 0 0; font-size: 13.5px; line-height: 1.5; color: rgba(255, 255, 255, 0.82); white-space: pre-wrap; overflow-wrap: anywhere; }
.pd-entry-changes { margin: 4px 0 0; padding-left: 17px; font-size: 13.5px; line-height: 1.5; color: rgba(255, 255, 255, 0.82); }
.pd-entry-changes li::marker { color: var(--pd-k); }
.pd-time {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; white-space: nowrap;
font-size: 12px; font-weight: 700; background: rgba(255, 255, 255, 0.07); border: 1px solid var(--kb-border-strong);
}
.pd-time-ico { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; opacity: 0.75; }
/* Tab "Fortschritt": je Auftrag eine Karte in der Farbe seines Status */
#activity-body:has(.pd-order-card) { display: grid; gap: 16px; padding: 0; background: none; border: none; }
.pd-order-card > .pd-collapsible, .pd-order-card > .pd-empty { margin-top: 18px; }
.pd-order-card .pd-entry { background: rgba(0, 0, 0, 0.28); }
/* ------------------------------------------------- Tabellen und Pills --- */
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.simple-table th, .simple-table td { padding: 10px 10px; text-align: left; border-bottom: 1px solid var(--kb-border); }
.simple-table th { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint); }
@@ -256,13 +359,7 @@
.pill-warn { color: #d8b4fe; background: rgba(157, 61, 254, 0.16); border-color: rgba(157, 61, 254, 0.5); }
.pill-red { color: #fca5a5; background: rgba(248, 113, 113, 0.12); border-color: rgba(248, 113, 113, 0.45); }
.pill-muted { color: var(--kb-muted); background: rgba(255, 255, 255, 0.06); border-color: var(--kb-border-strong); }
.pill-git { color: #fdba74; background: rgba(251, 146, 60, 0.12); border-color: rgba(251, 146, 60, 0.45); }
.pill-amber { color: #fcd34d; background: rgba(251, 191, 36, 0.12); border-color: rgba(251, 191, 36, 0.45); }
.activity-item { padding: 14px 0; border-bottom: 1px solid var(--kb-border); }
.activity-item:first-child { padding-top: 0; }
.activity-item:last-child { border-bottom: none; padding-bottom: 0; }
.activity-status { display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--kb-border-strong); }
.activity-ws { margin: 4px 0 4px 14px; font-size: 13px; color: rgba(255, 255, 255, 0.78); white-space: pre-wrap; }
/* ------------------------------------------------------------------- Chat --- */
.chat-box { display: flex; flex-direction: column; height: 520px; }
@@ -554,18 +651,22 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.kb-project-top h2 { font-size: 19px; }
.kb-open { right: 10px; bottom: 10px; padding: 7px 12px; font-size: 12.5px; }
.kb-chip { padding: 7px 12px; font-size: 13px; }
.kb-project [data-details-btn] { width: 100%; justify-content: center; padding: 12px 16px; font-size: 14px; }
/* Projektdetails: der Knopf oeffnet eine eigene Seite (Pfeil nach rechts wie eine Listenzeile) */
.kb-project [data-details-btn] { width: 100%; justify-content: space-between; padding: 14px 16px 14px 18px; font-size: 15px; }
.kb-project .kb-details-chev { transform: none; width: 18px; height: 18px; opacity: 0.7; }
.kb-details .simple-table { font-size: 13px; }
/* Fortschritt als Zeitleiste */
#activity-body .activity-item { position: relative; padding: 0 0 18px 22px; border-bottom: none; }
#activity-body .activity-item::before {
content: ''; position: absolute; left: 0; top: 7px; width: 9px; height: 9px; border-radius: 50%;
background: var(--kb-purple); box-shadow: 0 0 0 3px rgba(157, 61, 254, 0.22);
}
#activity-body .activity-item::after { content: ''; position: absolute; left: 4px; top: 22px; bottom: 2px; width: 1px; background: var(--kb-border-strong); }
#activity-body .activity-item:last-child { padding-bottom: 0; }
#activity-body .activity-item:last-child::after { display: none; }
.kb-details.pd-page { margin: 0; padding: 0; border-top: none; gap: 26px; }
.pd-order { padding: 16px; border-radius: 18px; }
.pd-order-name h3 { font-size: 17px; }
.pd-steps { gap: 5px; }
.pd-steps li { font-size: 11.5px; letter-spacing: -0.01em; }
.pd-change-head strong { font-size: 15px; }
.pd-change-details { font-size: 13.5px; }
.pd-entry { grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; padding: 12px; border-radius: 16px; }
.pd-entry .pd-time { grid-column: 2; justify-self: start; margin-top: 6px; }
.pd-entry-text, .pd-entry-changes { font-size: 14px; }
.pd-more { width: 100%; padding: 12px 14px; font-size: 14px; }
.pd-order-hint .kb-link { display: inline-block; padding: 6px 0; }
/* Rechnungen: jede Zeile als Karte */
#invoices-body:has(.kb-invoices) { padding: 0; background: none; border: none; }
@@ -689,6 +790,6 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.kb-shell.dashboard-content-reveal, .kb-view.active, .kb-sheet[open] .kb-sheet-panel, #loading:not(.hidden) { animation: none !important; }
.kb-header, .kb-nav, .kb-logo, .kb-app-title, .kb-sheet-panel { transition: none !important; }
.kb-shell.dashboard-content-reveal, .kb-view.active, .kb-sheet[open] .kb-sheet-panel, #loading:not(.hidden), .pd-status::before { animation: none !important; }
.kb-header, .kb-nav, .kb-logo, .kb-app-title, .kb-sheet-panel, .kb-details-chev { transition: none !important; }
}