diff --git a/package.json b/package.json index e1c749c..5012b2f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node server/index.js", "dev": "node --watch server/index.js", + "test": "node --test test/*.test.js", "setup:check": "node scripts/portal-setup.mjs --check", "setup:link": "node scripts/portal-setup.mjs --link" }, diff --git a/public/app.js b/public/app.js index 8b46ec1..4929d4a 100644 --- a/public/app.js +++ b/public/app.js @@ -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) { ${extraChips.map((label) => `${escapeHtml(label)}`).join('')} - +
` @@ -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 - ? `Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}
` +/* --------------------------------------------------------- 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: 'Noch keine Änderungen vorhanden.
` - } - 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 ` -Wir brauchen noch eine Info von dir.
' + : '' + return ` +Noch keine Änderungen an der Website.
' + 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 ` +${escapeHtml(g.day)}
` : ''} +${escapeHtml(e.text)}
` : '' + return ` +${emptyText}
` + return ` +Keine Datei-Informationen verfügbar.
' + return 'Keine Datei-Informationen verfügbar.
' } const rows = (files.topLevel || []).map((entry) => `${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}
-| Ordner / Datei | Dateien | Größe |
|---|
Für dieses Projekt gibt es noch kein Ticket.
' - } - const head = `Ticket #${escapeHtml(work.ticket.woid)} „${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}
` - if (!work.worksheets.length) { - return `${head}Noch keine dokumentierten Arbeiten.
` - } - return head + work.worksheets.map((ws) => ` -Lade Details…
' +// 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 = 'Details werden geladen…
' 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 = ` -Git-Daten derzeit nicht verfügbar.
'} -Datei-Übersicht derzeit nicht verfügbar.
'} -Keine Ticket-Daten verfügbar.
'} - ` + ${order ? `Die Änderungen können gerade nicht geladen werden.
')} + ${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“.') + : 'Die Arbeiten können gerade nicht geladen werden.
') : ''} + ${pdSection('files', 'Dateien', files ? renderFileOverview(files) : 'Die Datei-Übersicht kann gerade nicht geladen werden.
')}` + 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) => ` -Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}
` } @@ -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() }) diff --git a/public/dashboard.html b/public/dashboard.html index 3a311c6..7db6210 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -30,7 +30,7 @@