From ba9916cfe6e860e43236ad09ce5e0e3ab2cb4f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:40:19 +0000 Subject: [PATCH] =?UTF-8?q?Projektdetails=20und=20Fortschritt=20kundenvers?= =?UTF-8?q?t=C3=A4ndlich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 1 + public/app.js | 303 +++++++++++++++++------- public/dashboard.html | 12 +- public/kundenbereich.css | 155 +++++++++--- server/routes/customerData.js | 8 +- server/routes/projects.js | 44 ++-- server/services/customerProjectView.js | 233 ++++++++++++++++++ server/services/projectInsights.js | 3 +- test/customerProjectView.test.js | 311 +++++++++++++++++++++++++ 9 files changed, 932 insertions(+), 138 deletions(-) create mode 100644 server/services/customerProjectView.js create mode 100644 test/customerProjectView.test.js 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: '', + online: '', + onsite: '', + office: '', + work: '', + clock: '', + changes: '', + files: '', +} +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 `` +} + +// "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 + ? `
    ${ORDER_STEPS.map((label, i) => { + const n = i + 1 + const state = n < status.step ? 'is-done' : n === status.step ? 'is-current' : '' + return `
  1. ${label}
  2. ` + }).join('')}
` : '' - if (!git.commits || !git.commits.length) { - return `${sizeInfo}

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 ` -
- ${escapeHtml(title)} - ${bodyText ? `
${escapeHtml(bodyText)}
` : ''} -
${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}
-
` - }).join('') + const hint = status.tone === 'waiting' + ? '

Wir brauchen noch eine Info von dir.

' + : '' + return ` +
+
+ ${order.number ? `Auftrag ${escapeHtml(order.number)}` : ''} +

${escapeHtml(order.topic || 'Dein Auftrag')}

+
+ ${escapeHtml(status.label)} +
+ ${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 `` +} + +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 ` +
  • +
    + ${escapeHtml(c.title)} + ${time ? `` : ''} +
    + ${c.details?.length ? `
      ${c.details.map((t) => `
    • ${escapeHtml(t)}
    • `).join('')}
    ` : ''} +
  • ` +} + +// Zeitleiste der letzten Aenderungen, nach Tagen gruppiert +function renderChanges(changes) { + if (!changes.length) 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 ` +
    +
    + ${groups.map((g) => ` +
    = PD_CHANGES_SHOWN ? ' data-extra' : ''}> + ${g.day ? `

    ${escapeHtml(g.day)}

    ` : ''} +
      ${g.items.join('')}
    +
    `).join('')} +
    + ${pdMoreButton(changes.length, PD_CHANGES_SHOWN, ['weitere Änderung', 'weitere Änderungen'])} +
    ` +} + +function renderWorkEntry(e, extra) { + const meta = [fmtIsoDay(e.date), e.person].filter(Boolean).map(escapeHtml).join(' · ') + const body = e.changes?.length + ? `
      ${e.changes.map((t) => `
    • ${escapeHtml(t)}
    • `).join('')}
    ` + : e.text ? `

    ${escapeHtml(e.text)}

    ` : '' + return ` +
  • + ${pdIcon(e.kind)} +
    +
    + ${escapeHtml(e.label)} + ${meta ? `` : ''} +
    + ${body} +
    + ${e.minutes ? `${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}` : ''} +
  • ` +} + +function renderWorkEntries(entries, emptyText) { + if (!entries.length) return `

    ${emptyText}

    ` + return ` +
    +
      ${entries.map((e, i) => renderWorkEntry(e, i >= PD_ENTRIES_SHOWN)).join('')}
    + ${pdMoreButton(entries.length, PD_ENTRIES_SHOWN, ['weiteren Eintrag', 'weitere Einträge'])} +
    ` +} + +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 '

    Keine Datei-Informationen verfügbar.

    ' + return '

    Keine Datei-Informationen verfügbar.

    ' } const rows = (files.topLevel || []).map((entry) => ` @@ -216,50 +354,53 @@ function renderFileOverview(files) { ${fmtBytes(entry.sizeBytes)} `).join('') return ` -

    ${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}

    - +

    ${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}

    +
    ${rows} -
    Ordner / DateiDateienGröße
    ` + ` } -function renderWorkEntries(work) { - if (!work.ticket) { - return '

    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) => ` -
    -
    - ${ws.isGit ? 'Git-Push' : `${escapeHtml(ws.serviceType || 'Arbeit')}`} - ${escapeHtml(ws.employeeName)} - · ${fmtDateShort(ws.date)} - ${ws.totalTime ? `${fmtMinutes(ws.totalTime)}` : ''} - ${ws.pending ? 'Zeiterfassung folgt' : ''} -
    -
    ${escapeHtml(ws.details)}
    -
    `).join('') +function pdSection(icon, title, body) { + return `

    ${pdIcon(icon)}${title}

    ${body}
    ` } -async function loadProjectDetails(project, box) { - box.innerHTML = '

    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 = ` -

    Letzte Änderungen

    - ${git ? renderCommits(git) : '

    Git-Daten derzeit nicht verfügbar.

    '} -

    Datei-Übersicht

    - ${files ? renderFileOverview(files) : '

    Datei-Übersicht derzeit nicht verfügbar.

    '} -

    Arbeiten am Projekt

    - ${work ? renderWorkEntries(work) : '

    Keine Ticket-Daten verfügbar.

    '} - ` + ${order ? `
    ${renderOrder(order)}
    ` : ''} + ${pdSection('changes', 'Letzte Änderungen', git + ? renderChanges(git.changes || []) + : '

    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) => ` -
    -
    - ${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')} - ${escapeHtml(t.status || '–')} - · ${fmtDateShort(t.createdAt)} -
    - ${(t.worksheets || []).map((w) => ` -
    - ${w.isGit ? 'Git-Push' : `${escapeHtml(w.serviceType || 'Arbeit')}`} - ${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''} - · ${fmtDateShort(w.date)} - ${Number(w.totalTime) ? `${fmtMinutes(w.totalTime)}` : ''} - ${w.pending ? 'Zeiterfassung folgt' : ''} - ${w.details ? `
    ${escapeHtml(w.details)}
    ` : ''} -
    - `).join('')} -
    `).join('') +
    + ${renderOrder(t)} + ${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')} +
    `).join('') + initPdToggles(body) } catch (err) { body.innerHTML = `

    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 @@
    - +