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()
})