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:
1 parent
e54f97144c
commit
ba9916cfe6
9 files changed
+930
-136
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
+222
-77
@@ -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>`
|
||||
: ''
|
||||
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>'
|
||||
: ''
|
||||
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 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>`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { customerOrder, customerWorkEntries } from '../services/customerProjectView.js'
|
||||
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -69,7 +70,12 @@ router.get('/activity', requireSession, async (req, res) => {
|
||||
// Interne Akquise-Tickets gehoeren nicht in die Kundenansicht
|
||||
excludeTypes: ['Akquise'],
|
||||
})
|
||||
return res.json({ tickets })
|
||||
// Status auf Deutsch, Arbeiten ohne Git-Technik (customerProjectView.js)
|
||||
const orders = tickets
|
||||
.map((t) => ({ order: customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) }))
|
||||
.filter((t) => t.order)
|
||||
.map(({ order, createdAt, entries }) => ({ ...order, createdAt, entries }))
|
||||
return res.json({ tickets: orders })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
|
||||
}
|
||||
|
||||
+15
-29
@@ -3,6 +3,7 @@ import { config } from '../config.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js'
|
||||
import { customerChanges, customerOrder, customerWorkEntries } from '../services/customerProjectView.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -40,9 +41,6 @@ router.get('/', requireSession, async (req, res) => {
|
||||
liveDomain: p.liveDomain || '',
|
||||
status: p.status || '',
|
||||
provisioningStatus: p.provisioningStatus || '',
|
||||
templateName: p.templateName || '',
|
||||
giteaRepoUrl: p.giteaRepoUrl || '',
|
||||
repoFullName: p.repoFullName || '',
|
||||
updatedAt: p.updatedAt || p.$updatedAt || '',
|
||||
}))
|
||||
|
||||
@@ -52,11 +50,11 @@ router.get('/', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Letzte Git-Pushes + Projektgroesse. */
|
||||
/** Letzte Aenderungen in Kundensprache (ohne Commit-Kuerzel, Kontonamen, KI-Hinweise). */
|
||||
router.get('/:id/git', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const { repo, commits } = await getProjectGitInsights(req.project)
|
||||
return res.json({ repo, commits })
|
||||
const { commits } = await getProjectGitInsights(req.project)
|
||||
return res.json({ changes: customerChanges(commits) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Git-Daten konnten nicht geladen werden' })
|
||||
}
|
||||
@@ -73,40 +71,28 @@ router.get('/:id/files', requireSession, loadOwnProject, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Ticket-Arbeiten am Projekt (inkl. Git-Push-Worksheets, "Zeit offen" markiert). */
|
||||
/**
|
||||
* Auftrag zum Projekt (Status auf Deutsch) und dokumentierte Arbeiten. Git-Pushes ohne
|
||||
* Arbeitszeit stehen schon unter "Letzte Änderungen"; interne Tickets (Akquise) bleiben verborgen.
|
||||
*/
|
||||
router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => {
|
||||
try {
|
||||
const project = req.project
|
||||
if (!project.ticketId) {
|
||||
return res.json({ ticket: null, worksheets: [] })
|
||||
const ticket = project.ticketId
|
||||
? await getDocument(config.collections.workorders, project.ticketId).catch(() => null)
|
||||
: null
|
||||
const order = customerOrder(ticket)
|
||||
if (!order) {
|
||||
return res.json({ order: null, entries: [] })
|
||||
}
|
||||
|
||||
const ticket = await getDocument(config.collections.workorders, project.ticketId).catch(() => null)
|
||||
const worksheets = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', project.ticketId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(50),
|
||||
]).catch(() => [])
|
||||
|
||||
const sanitized = worksheets
|
||||
// GIT-Eintraege einschliessen, sonstige reine Kommentare ausblenden
|
||||
.filter((ws) => ws.serviceType === 'GIT' || !ws.isComment)
|
||||
.map((ws) => ({
|
||||
wsid: ws.wsid || '',
|
||||
date: ws.endDate || ws.startDate || '',
|
||||
time: ws.endTime || ws.startTime || '',
|
||||
employeeName: ws.employeeName || ws.employeeShort || '',
|
||||
serviceType: ws.serviceType || '',
|
||||
details: ws.details || '',
|
||||
totalTime: Number(ws.totalTime || 0),
|
||||
isGit: ws.serviceType === 'GIT',
|
||||
pending: ws.serviceType === 'GIT' && !ws.startTime,
|
||||
}))
|
||||
|
||||
return res.json({
|
||||
ticket: ticket ? { woid: ticket.woid || '', topic: ticket.topic || '', status: ticket.status || '' } : null,
|
||||
worksheets: sanitized,
|
||||
})
|
||||
return res.json({ order, entries: customerWorkEntries(worksheets, { includeUpdates: false }) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Kundenansicht der Projektdaten: Commits, Ticketstatus und Arbeitseintraege so aufbereiten,
|
||||
* dass Endkunden sie verstehen. Alles Technische (Commit-Kuerzel, Kontonamen, Git-Links,
|
||||
* KI-Hinweise wie "Co-Authored-By: Claude ...") bleibt auf dem Server.
|
||||
* Admin-Ansichten nutzen weiter die Rohdaten.
|
||||
*/
|
||||
|
||||
const GENERIC_UPDATE = 'Website aktualisiert'
|
||||
|
||||
/* --------------------------------------------------------------- Commits --- */
|
||||
|
||||
// Zeilen, die nie beim Kunden landen: Git-Trailer (Co-Authored-By, Signed-off-by, ...),
|
||||
// Hinweise von KI-Werkzeugen und die Standardzeile von "git revert"
|
||||
const HIDDEN_LINES = [
|
||||
/^[\w-]+-by\s*:/i,
|
||||
/^(change-id|cc|made-with)\s*:/i,
|
||||
/^\W*generated (with|by)\b/i,
|
||||
/noreply@anthropic\.com/i,
|
||||
/claude\.(ai|com)\/(code|claude-code)\b/i,
|
||||
/^this reverts commit [0-9a-f]{7,40}/i,
|
||||
]
|
||||
|
||||
// Rein technische Commits (Vorschau-Konfiguration, Merges) - fuer den Kunden ohne Inhalt
|
||||
const HIDDEN_TITLES = [
|
||||
/^update preview config\b/i,
|
||||
/^preview-config\b/i,
|
||||
/^hosting: preview aktivieren\b/i,
|
||||
/^migration: preview\b/i,
|
||||
/^merge (branch|pull request|remote-tracking branch|tag)\b/i,
|
||||
]
|
||||
|
||||
// Feste Texte unserer Automatisierung -> verstaendliche Fassung
|
||||
const TRANSLATED_TITLES = [
|
||||
[/^initial commit$/i, () => 'Projekt angelegt'],
|
||||
[/^automatisch erstellte preview-seite$/i, () => 'Erste Vorschau der Website erstellt'],
|
||||
[/^anreicherung:\s*(.+?)(\s*\(cloud-routine\))?$/i, (m) => `Inhalte ergänzt: ${m[1]}`],
|
||||
[/^google fonts self-hosten\b/i, () => 'Schriftarten datenschutzkonform eingebunden (DSGVO)'],
|
||||
[/^website \S+ – aus \S+, auf .+ angepasst$/i, () => 'Website erstellt und an den Betrieb angepasst'],
|
||||
]
|
||||
|
||||
// "feat(roadmaps): ...", "fix: ..." (Conventional Commits)
|
||||
const TYPE_PREFIX = /^(feat|fix|hotfix|chore|docs|style|refactor|perf|tests?|build|ci|wip)(\([^)]*\))?!?:\s*/i
|
||||
|
||||
const MAX_DETAILS = 6
|
||||
const MAX_DETAIL_LENGTH = 300
|
||||
|
||||
function isHiddenLine(line) {
|
||||
return HIDDEN_LINES.some((re) => re.test(line.trim()))
|
||||
}
|
||||
|
||||
function stripMarkdown(text) {
|
||||
return text.replace(/`/g, '')
|
||||
}
|
||||
|
||||
function capitalize(text) {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1)
|
||||
}
|
||||
|
||||
/** Commit-Titel fuer den Kunden, oder null wenn der Commit nichts Sichtbares aendert. */
|
||||
function customerCommitTitle(rawTitle) {
|
||||
let title = stripMarkdown(String(rawTitle || '').trim())
|
||||
|
||||
const revert = /^revert "(.+)"$/i.exec(title)
|
||||
if (revert) {
|
||||
const inner = customerCommitTitle(revert[1])
|
||||
return inner ? `„${inner}“ zurückgenommen` : null
|
||||
}
|
||||
|
||||
title = title.replace(TYPE_PREFIX, '').trim()
|
||||
if (HIDDEN_TITLES.some((re) => re.test(title))) return null
|
||||
for (const [re, translate] of TRANSLATED_TITLES) {
|
||||
const match = re.exec(title)
|
||||
if (match) return translate(match)
|
||||
}
|
||||
// "fix", "upload", "pagev1", "webseite-version-01": ein kleingeschriebenes Wort sagt dem Kunden nichts
|
||||
if (!title || /^[a-z0-9._-]+$/.test(title)) return GENERIC_UPDATE
|
||||
return capitalize(title)
|
||||
}
|
||||
|
||||
function clip(text) {
|
||||
return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1).trimEnd()}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit-Beschreibung als Liste kurzer Punkte. Git bricht Zeilen hart um (72 Zeichen);
|
||||
* Folgezeilen werden wieder an den Punkt bzw. Absatz davor gehaengt.
|
||||
*/
|
||||
function customerCommitDetails(bodyLines, title) {
|
||||
const items = []
|
||||
let open = false
|
||||
for (const raw of bodyLines) {
|
||||
const line = stripMarkdown(raw.trim())
|
||||
if (!line || isHiddenLine(line)) {
|
||||
open = false
|
||||
continue
|
||||
}
|
||||
const bullet = /^[-*•]\s+(.*)$/.exec(line)
|
||||
if (bullet) {
|
||||
items.push(bullet[1])
|
||||
open = true
|
||||
} else if (open) {
|
||||
const prev = items[items.length - 1]
|
||||
items[items.length - 1] = prev.endsWith('-') ? prev + line : `${prev} ${line}`
|
||||
} else {
|
||||
items.push(line)
|
||||
open = true
|
||||
}
|
||||
}
|
||||
return items.filter((item) => item && item !== title).slice(0, MAX_DETAILS).map(clip)
|
||||
}
|
||||
|
||||
/** Commits aus listRepoCommits() -> [{ date, title, details }] ohne Kuerzel, Autor und Link. */
|
||||
export function customerChanges(commits = []) {
|
||||
const changes = []
|
||||
for (const commit of commits) {
|
||||
const [firstLine = '', ...bodyLines] = String(commit?.message || '').trim().split('\n')
|
||||
const title = customerCommitTitle(firstLine)
|
||||
if (!title) continue
|
||||
const date = commit.date || ''
|
||||
// Mehrere "Website aktualisiert" am selben Tag hintereinander sind fuer den Kunden eine Aenderung
|
||||
const prev = changes[changes.length - 1]
|
||||
if (prev && prev.title === title && prev.date.slice(0, 10) === date.slice(0, 10)) continue
|
||||
changes.push({ date, title, details: customerCommitDetails(bodyLines, title) })
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- Tickets / Auftrag --- */
|
||||
|
||||
// step: Position in der Fortschrittsleiste Eingegangen (1) -> In Arbeit (2) -> Prüfung (3) -> Erledigt (4);
|
||||
// 0 = ausserhalb (pausiert, storniert, abgebrochen)
|
||||
const TICKET_STATUS = {
|
||||
open: { label: 'Eingegangen', tone: 'new', step: 1 },
|
||||
assigned: { label: 'Eingeplant', tone: 'active', step: 2 },
|
||||
occupied: { label: 'In Arbeit', tone: 'active', step: 2 },
|
||||
'added info': { label: 'In Arbeit', tone: 'active', step: 2 },
|
||||
awaiting: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 },
|
||||
'in test': { label: 'Wird geprüft', tone: 'review', step: 3 },
|
||||
closed: { label: 'Erledigt', tone: 'done', step: 4 },
|
||||
halted: { label: 'Pausiert', tone: 'paused', step: 0 },
|
||||
cancelled: { label: 'Storniert', tone: 'stopped', step: 0 },
|
||||
aborted: { label: 'Abgebrochen', tone: 'stopped', step: 0 },
|
||||
}
|
||||
const UNKNOWN_STATUS = { label: 'In Bearbeitung', tone: 'active', step: 2 }
|
||||
|
||||
/** Ticketstatus aus dem Ticketsystem -> { label, tone, step } auf Deutsch. */
|
||||
export function customerTicketStatus(status) {
|
||||
const key = String(status || 'open').trim().toLowerCase()
|
||||
return { ...(TICKET_STATUS[key] || UNKNOWN_STATUS) }
|
||||
}
|
||||
|
||||
/** Interne Tickets (Akquise) gehoeren nicht in die Kundenansicht. */
|
||||
export function isInternalTicket(ticket) {
|
||||
return String(ticket?.type || '').trim().toLowerCase() === 'akquise' || /^akquise\s*:/i.test(String(ticket?.topic || '').trim())
|
||||
}
|
||||
|
||||
/** Ticket -> Auftrag fuer die Kundenansicht (null bei fehlendem oder internem Ticket). */
|
||||
export function customerOrder(ticket) {
|
||||
if (!ticket || isInternalTicket(ticket)) return null
|
||||
return {
|
||||
number: String(ticket.woid || ''),
|
||||
topic: String(ticket.topic || ticket.title || ''),
|
||||
status: customerTicketStatus(ticket.status),
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Arbeitseintraege --- */
|
||||
|
||||
const WORK_KINDS = {
|
||||
git: ['update', GENERIC_UPDATE],
|
||||
remote: ['online', 'Online erledigt'],
|
||||
'on site': ['onsite', 'Vor Ort bei dir'],
|
||||
'off site': ['office', 'In unserem Büro'],
|
||||
}
|
||||
const OTHER_WORK = ['work', 'Arbeit am Projekt']
|
||||
|
||||
// "24.09.2026" (Ticketsystem) -> "2026-09-24"
|
||||
function isoDay(value) {
|
||||
const text = String(value || '').trim()
|
||||
const de = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text)
|
||||
if (de) return `${de[3]}-${de[2]}-${de[1]}`
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : ''
|
||||
}
|
||||
|
||||
// Git-Push-Worksheet (gitWorksheet.js): "- [sha](url): Titel (Autor)" je Commit
|
||||
function changesFromPushDetails(details) {
|
||||
const titles = []
|
||||
for (const line of String(details || '').split('\n')) {
|
||||
const match = /^- \[[0-9a-f]{7,40}\]\([^)]*\):\s*(.+)$/i.exec(line.trim())
|
||||
if (!match) continue
|
||||
const title = customerCommitTitle(match[1].replace(/\s*\([^()]*\)\s*$/, ''))
|
||||
if (title && !titles.includes(title)) titles.push(title)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
function customerWorkEntry(sheet, { includeUpdates }) {
|
||||
const type = String(sheet?.serviceType || '').trim().toLowerCase()
|
||||
if (type === 'comment') return null
|
||||
const isGit = type === 'git'
|
||||
// Reine Kommentare sind intern; Git-Pushes legt das Ticketsystem ebenfalls als Kommentar an
|
||||
if (sheet.isComment && !isGit) return null
|
||||
|
||||
const [kind, label] = WORK_KINDS[type] || OTHER_WORK
|
||||
const date = isoDay(sheet.date || sheet.endDate || sheet.startDate)
|
||||
const minutes = Number(sheet.totalTime) || 0
|
||||
|
||||
if (isGit) {
|
||||
// In den Projektdetails stehen die Aenderungen schon unter "Letzte Änderungen";
|
||||
// dort zaehlt ein Push nur, wenn Arbeitszeit nachgetragen wurde
|
||||
if (!includeUpdates && !minutes) return null
|
||||
const changes = changesFromPushDetails(sheet.details)
|
||||
return changes.length ? { kind, label, date, minutes, changes } : null
|
||||
}
|
||||
return { kind, label, date, minutes, person: String(sheet.employeeName || '').trim(), text: String(sheet.details || '').trim() }
|
||||
}
|
||||
|
||||
/** Worksheets (roh aus Appwrite oder aus getCustomerActivity) -> Eintraege fuer den Kunden. */
|
||||
export function customerWorkEntries(sheets = [], { includeUpdates = true } = {}) {
|
||||
const entries = []
|
||||
for (const entry of sheets.map((sheet) => customerWorkEntry(sheet, { includeUpdates }))) {
|
||||
if (!entry) continue
|
||||
// Mehrere Pushes an einem Tag: ein Eintrag "Website aktualisiert" mit allen Aenderungen
|
||||
const prev = entries[entries.length - 1]
|
||||
if (entry.kind === 'update' && prev?.kind === 'update' && prev.date === entry.date) {
|
||||
prev.minutes += entry.minutes
|
||||
prev.changes.push(...entry.changes.filter((change) => !prev.changes.includes(change)))
|
||||
continue
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export async function getProjectGitInsights(project) {
|
||||
const repo = await getRepoInfo(repoFullName)
|
||||
const commits = await listRepoCommits(repoFullName, {
|
||||
branch: repo?.defaultBranch || 'main',
|
||||
limit: 20,
|
||||
// Technische Commits blendet die Kundenansicht aus - etwas mehr holen, damit genug bleiben
|
||||
limit: 30,
|
||||
})
|
||||
return { repo, commits }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
customerChanges,
|
||||
customerTicketStatus,
|
||||
isInternalTicket,
|
||||
customerOrder,
|
||||
customerWorkEntries,
|
||||
} from '../server/services/customerProjectView.js'
|
||||
|
||||
// Commit wie listRepoCommits() ihn liefert (giteaAdmin.js)
|
||||
function commit(message, date = '2026-09-24T12:05:00Z') {
|
||||
return {
|
||||
sha: '88d3c37ab7b15337772cf59a5a9294ec0cc8cca4',
|
||||
shortSha: '88d3c37',
|
||||
message,
|
||||
authorName: 'Claude',
|
||||
date,
|
||||
url: 'https://git.webklar.com/knso/ff-lack/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4',
|
||||
}
|
||||
}
|
||||
|
||||
const titles = (commits) => customerChanges(commits).map((c) => c.title)
|
||||
|
||||
/* --------------------------------------------------------- Änderungen --- */
|
||||
|
||||
test('Änderung: KI-Trailer fallen weg, Kürzel/Autor/Link werden nicht ausgeliefert', () => {
|
||||
const changes = customerChanges([
|
||||
commit('Öffnungszeiten für den Winter\n\nTeamfotos ausgetauscht\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\n'),
|
||||
])
|
||||
assert.deepEqual(changes, [
|
||||
{ date: '2026-09-24T12:05:00Z', title: 'Öffnungszeiten für den Winter', details: ['Teamfotos ausgetauscht'] },
|
||||
])
|
||||
})
|
||||
|
||||
test('Änderung: auch andere Modelle, Tools und Git-Trailer fallen weg', () => {
|
||||
const [change] = customerChanges([
|
||||
commit([
|
||||
'Kontaktformular',
|
||||
'',
|
||||
'Co-authored-by: Copilot <copilot@github.com>',
|
||||
'Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>',
|
||||
'Signed-off-by: Kenso Grimm <kenso@webklar.com>',
|
||||
'Change-Id: I8d2f0a1b',
|
||||
'🤖 Generated with [Claude Code](https://claude.com/claude-code)',
|
||||
'https://claude.ai/code/session_01AbCdEf',
|
||||
].join('\n')),
|
||||
])
|
||||
assert.deepEqual(change.details, [])
|
||||
})
|
||||
|
||||
test('Änderung: Präfixe wie feat(...)/fix: verschwinden, erster Buchstabe groß', () => {
|
||||
assert.deepEqual(
|
||||
titles([commit('feat(roadmaps): Ticket-Integration'), commit('fix: kaputten Link im Footer repariert', '2026-09-23T10:00:00Z')]),
|
||||
['Ticket-Integration', 'Kaputten Link im Footer repariert']
|
||||
)
|
||||
})
|
||||
|
||||
test('Änderung: rein technische Commits werden ausgeblendet', () => {
|
||||
assert.deepEqual(
|
||||
titles([
|
||||
commit('Update preview config from ticket system\n'),
|
||||
commit('Preview-Config: unger-reisen.project.webklar.com\n'),
|
||||
commit('Hosting: Preview aktivieren (automatisch)\n'),
|
||||
commit('Migration: Preview auf Pipeline-Repo umgezogen (identica-kaiser)\n'),
|
||||
commit("Merge branch 'main' of https://git.webklar.com/knso/lackwerk-kl\n"),
|
||||
commit("Merge pull request 'Neue Startseite' (#3) from dev into main\n"),
|
||||
]),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('Änderung: bekannte Automatik-Texte werden übersetzt', () => {
|
||||
const day = (d) => `2026-09-${d}T08:00:00Z`
|
||||
assert.deepEqual(
|
||||
titles([
|
||||
commit('Initial commit\n', day(10)),
|
||||
commit('Automatisch erstellte Preview-Seite\n', day(11)),
|
||||
commit('Anreicherung: Bilder, Logo, Text-Feinarbeit (Cloud-Routine)\n', day(12)),
|
||||
commit('Google Fonts self-hosten (DSGVO): assets/fonts.css statt fonts.googleapis.com\n', day(13)),
|
||||
commit('Website ff-lack – aus tamplate-lack, auf Fahrzeuglackierer Fruck – Altenglan angepasst\n', day(14)),
|
||||
]),
|
||||
[
|
||||
'Projekt angelegt',
|
||||
'Erste Vorschau der Website erstellt',
|
||||
'Inhalte ergänzt: Bilder, Logo, Text-Feinarbeit',
|
||||
'Schriftarten datenschutzkonform eingebunden (DSGVO)',
|
||||
'Website erstellt und an den Betrieb angepasst',
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('Änderung: nichtssagende Titel werden zu "Website aktualisiert", echte Wörter bleiben', () => {
|
||||
const day = (d) => `2026-09-${d}T08:00:00Z`
|
||||
assert.deepEqual(
|
||||
titles([
|
||||
commit('fix\n', day(10)),
|
||||
commit('upload\n', day(11)),
|
||||
commit('pagev1\n', day(12)),
|
||||
commit('webseite-version-01\n', day(13)),
|
||||
commit('Teamseite\n', day(14)),
|
||||
]),
|
||||
['Website aktualisiert', 'Website aktualisiert', 'Website aktualisiert', 'Website aktualisiert', 'Teamseite']
|
||||
)
|
||||
})
|
||||
|
||||
test('Änderung: Revert wird verständlich, die "This reverts commit"-Zeile fällt weg', () => {
|
||||
const [change] = customerChanges([
|
||||
commit('Revert "Anreicherung: Bilder, Logo, Farbwelt, Texte (Cloud-Routine)"\n\nThis reverts commit 4ff6e7a0348c5553769fbff2ac4bc1e44817ff28.\n'),
|
||||
])
|
||||
assert.deepEqual(change, {
|
||||
date: '2026-09-24T12:05:00Z',
|
||||
title: '„Inhalte ergänzt: Bilder, Logo, Farbwelt, Texte“ zurückgenommen',
|
||||
details: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Änderung: umbrochene Aufzählungen und Absätze werden wieder zusammengefügt', () => {
|
||||
const [bullets] = customerChanges([
|
||||
commit([
|
||||
'Website stärker personalisiert',
|
||||
'',
|
||||
'- Echte Betriebsfotos statt Stock: Luftbild als Hero, Galerie',
|
||||
' mit rotem Lackier-Fahrzeug, Farbtonanalyse-',
|
||||
' Mischsystem',
|
||||
'- Marken-Rot statt Blau',
|
||||
'',
|
||||
'Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>',
|
||||
].join('\n')),
|
||||
])
|
||||
assert.deepEqual(bullets.details, [
|
||||
'Echte Betriebsfotos statt Stock: Luftbild als Hero, Galerie mit rotem Lackier-Fahrzeug, Farbtonanalyse-Mischsystem',
|
||||
'Marken-Rot statt Blau',
|
||||
])
|
||||
|
||||
const [paragraphs] = customerChanges([
|
||||
commit('Planboard\n\nDas Planboard war eine Hülle - sieben Spalten\nmit "Keine Termine".\n\nZweiter Absatz.\n'),
|
||||
])
|
||||
assert.deepEqual(paragraphs.details, ['Das Planboard war eine Hülle - sieben Spalten mit "Keine Termine".', 'Zweiter Absatz.'])
|
||||
})
|
||||
|
||||
test('Änderung: Backticks aus Markdown verschwinden', () => {
|
||||
assert.deepEqual(titles([commit('Kontakt: `info@autohaus-meier.de` ergänzt')]), ['Kontakt: info@autohaus-meier.de ergänzt'])
|
||||
})
|
||||
|
||||
test('Änderung: gleiche Titel am selben Tag stehen nur einmal da, an verschiedenen Tagen zweimal', () => {
|
||||
const changes = customerChanges([
|
||||
commit('fix\n', '2026-09-24T15:00:00Z'),
|
||||
commit('upload\n', '2026-09-24T09:00:00Z'),
|
||||
commit('fix\n', '2026-09-20T09:00:00Z'),
|
||||
])
|
||||
assert.deepEqual(changes.map((c) => c.date), ['2026-09-24T15:00:00Z', '2026-09-20T09:00:00Z'])
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------ Ticketstatus --- */
|
||||
|
||||
test('Status: kein Status aus dem Ticketsystem kommt unübersetzt beim Kunden an', () => {
|
||||
const woms = ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info', 'In Test', 'Halted', 'Aborted', 'Cancelled', 'Closed']
|
||||
for (const status of woms) {
|
||||
const { label, step } = customerTicketStatus(status)
|
||||
assert.notEqual(label, status, status)
|
||||
assert.ok(Number.isInteger(step) && step >= 0 && step <= 4, `${status}: step ${step}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Status: Fortschritt und Ton passen zur Bedeutung', () => {
|
||||
assert.deepEqual(customerTicketStatus('Open'), { label: 'Eingegangen', tone: 'new', step: 1 })
|
||||
assert.deepEqual(customerTicketStatus('Occupied'), { label: 'In Arbeit', tone: 'active', step: 2 })
|
||||
assert.deepEqual(customerTicketStatus('Awaiting'), { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 })
|
||||
assert.deepEqual(customerTicketStatus('In Test'), { label: 'Wird geprüft', tone: 'review', step: 3 })
|
||||
assert.deepEqual(customerTicketStatus('Closed'), { label: 'Erledigt', tone: 'done', step: 4 })
|
||||
assert.deepEqual(customerTicketStatus('Cancelled'), { label: 'Storniert', tone: 'stopped', step: 0 })
|
||||
})
|
||||
|
||||
test('Status: leer heißt "Eingegangen", Unbekanntes wird nie roh durchgereicht', () => {
|
||||
assert.equal(customerTicketStatus('').label, 'Eingegangen')
|
||||
assert.equal(customerTicketStatus(undefined).label, 'Eingegangen')
|
||||
assert.deepEqual(customerTicketStatus('Zugesagt'), { label: 'In Bearbeitung', tone: 'active', step: 2 })
|
||||
})
|
||||
|
||||
/* ------------------------------------------------- Auftrag / Akquise --- */
|
||||
|
||||
test('Auftrag: Akquise-Tickets sind intern, auch wenn nur der Betreff so beginnt', () => {
|
||||
assert.equal(isInternalTicket({ type: 'Akquise', topic: 'Akquise: Mover GmbH' }), true)
|
||||
assert.equal(isInternalTicket({ type: 'akquise', topic: 'Lead' }), true)
|
||||
assert.equal(isInternalTicket({ type: 'Webpage', topic: 'Akquise: Mover GmbH' }), true)
|
||||
assert.equal(isInternalTicket({ type: 'Webpage', topic: 'Relaunch der Website' }), false)
|
||||
})
|
||||
|
||||
test('Auftrag: Nummer, Betreff und übersetzter Status; intern -> null', () => {
|
||||
assert.deepEqual(customerOrder({ woid: '100004', topic: 'Demo Webseite', status: 'Closed', type: 'Webpage' }), {
|
||||
number: '100004',
|
||||
topic: 'Demo Webseite',
|
||||
status: { label: 'Erledigt', tone: 'done', step: 4 },
|
||||
})
|
||||
assert.equal(customerOrder({ woid: '100015', topic: 'Akquise: Mover GmbH', status: 'Open', type: 'Akquise' }), null)
|
||||
assert.equal(customerOrder(null), null)
|
||||
})
|
||||
|
||||
/* --------------------------------------------------- Arbeitseinträge --- */
|
||||
|
||||
// Worksheet wie gitWorksheet.js ihn beim Push anlegt
|
||||
const gitSheet = {
|
||||
serviceType: 'GIT',
|
||||
employeeName: 'knso',
|
||||
employeeShort: 'GIT',
|
||||
totalTime: 0,
|
||||
startTime: '',
|
||||
startDate: '24.09.2026',
|
||||
endDate: '24.09.2026',
|
||||
endTime: '1405',
|
||||
isComment: true,
|
||||
details: [
|
||||
'Git Push auf `knso/ff-lack` (Branch: main)',
|
||||
'Commit: 88d3c37',
|
||||
'',
|
||||
'- [88d3c37](https://git.webklar.com/knso/ff-lack/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): Hintergrundbilder leicht rot einfaerben (knso)',
|
||||
' Dezenter roter Tint auf den Hintergrundbildern',
|
||||
' Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>',
|
||||
'- [1234567](https://git.webklar.com/knso/ff-lack/commit/1234567890abcdef1234567890abcdef12345678): Update preview config from ticket system (knso)',
|
||||
'- [2ca14eb](https://git.webklar.com/knso/ff-lack/commit/2ca14ebd93d53c45dee69e1beb8e668c4ce2a016): Anreicherung: Bilder, Logo, Text-Feinarbeit (Cloud-Routine) (webklar-automation)',
|
||||
].join('\n'),
|
||||
}
|
||||
|
||||
test('Arbeit: Git-Push wird zu "Website aktualisiert" mit bereinigten Änderungstiteln', () => {
|
||||
assert.deepEqual(customerWorkEntries([gitSheet], { includeUpdates: true }), [
|
||||
{
|
||||
kind: 'update',
|
||||
label: 'Website aktualisiert',
|
||||
date: '2026-09-24',
|
||||
minutes: 0,
|
||||
changes: ['Hintergrundbilder leicht rot einfaerben', 'Inhalte ergänzt: Bilder, Logo, Text-Feinarbeit'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('Arbeit: mehrere Pushes am selben Tag werden ein Eintrag, andere Tage bleiben getrennt', () => {
|
||||
const push = (day, sha, title, totalTime = 0) => ({
|
||||
...gitSheet,
|
||||
endDate: day,
|
||||
totalTime,
|
||||
details: `Git Push auf \`knso/ff-lack\` (Branch: main)\nCommit: ${sha}\n\n- [${sha}](https://git.webklar.com/knso/ff-lack/commit/${sha}): ${title} (knso)`,
|
||||
})
|
||||
const entries = customerWorkEntries(
|
||||
[
|
||||
push('24.09.2026', 'aaaaaaa', 'Handy-Ansicht überarbeitet', 15),
|
||||
push('24.09.2026', 'bbbbbbb', 'Trust-Icons repariert', 30),
|
||||
push('23.09.2026', 'ccccccc', 'Anfahrt ergänzt'),
|
||||
],
|
||||
{ includeUpdates: true }
|
||||
)
|
||||
assert.deepEqual(entries, [
|
||||
{ kind: 'update', label: 'Website aktualisiert', date: '2026-09-24', minutes: 45, changes: ['Handy-Ansicht überarbeitet', 'Trust-Icons repariert'] },
|
||||
{ kind: 'update', label: 'Website aktualisiert', date: '2026-09-23', minutes: 0, changes: ['Anfahrt ergänzt'] },
|
||||
])
|
||||
})
|
||||
|
||||
test('Arbeit: Push mit nur technischen Commits verschwindet ganz', () => {
|
||||
const onlyConfig = {
|
||||
...gitSheet,
|
||||
details: 'Git Push auf `knso/ff-lack` (Branch: main)\nCommit: 1234567\n\n- [1234567](https://git.webklar.com/x/y/commit/1234567): Update preview config from ticket system (knso)',
|
||||
}
|
||||
const noDetails = { ...gitSheet, details: 'Git Push auf `knso/ff-lack` (Branch: main)\nCommit: ?\n\n- (keine Commit-Details im Webhook)' }
|
||||
assert.deepEqual(customerWorkEntries([onlyConfig, noDetails], { includeUpdates: true }), [])
|
||||
})
|
||||
|
||||
test('Arbeit: ohne includeUpdates bleiben nur Git-Einträge mit nachgetragener Zeit', () => {
|
||||
const withTime = { ...gitSheet, totalTime: 45, startTime: '1320' }
|
||||
const entries = customerWorkEntries([gitSheet, withTime], { includeUpdates: false })
|
||||
assert.deepEqual(entries.map((e) => [e.kind, e.minutes]), [['update', 45]])
|
||||
})
|
||||
|
||||
test('Arbeit: manuelle Einträge bekommen deutsche Art, Person, Zeit und Text', () => {
|
||||
const sheet = (serviceType, extra = {}) => ({
|
||||
serviceType,
|
||||
employeeName: 'Kenso Grimm',
|
||||
totalTime: 150,
|
||||
startDate: '20.09.2026',
|
||||
endDate: '20.09.2026',
|
||||
details: 'Website neu erstellt und als Vorschau bereitgestellt.',
|
||||
isComment: false,
|
||||
...extra,
|
||||
})
|
||||
assert.deepEqual(customerWorkEntries([sheet('Remote')]), [
|
||||
{
|
||||
kind: 'online',
|
||||
label: 'Online erledigt',
|
||||
date: '2026-09-20',
|
||||
minutes: 150,
|
||||
person: 'Kenso Grimm',
|
||||
text: 'Website neu erstellt und als Vorschau bereitgestellt.',
|
||||
},
|
||||
])
|
||||
assert.deepEqual(
|
||||
customerWorkEntries([sheet('On Site'), sheet('Off Site'), sheet('Sonderwunsch')]).map((e) => [e.kind, e.label]),
|
||||
[['onsite', 'Vor Ort bei dir'], ['office', 'In unserem Büro'], ['work', 'Arbeit am Projekt']]
|
||||
)
|
||||
})
|
||||
|
||||
test('Arbeit: interne Kommentare tauchen nicht auf', () => {
|
||||
const comment = { serviceType: 'Remote', employeeName: 'Kenso Grimm', totalTime: 0, endDate: '20.09.2026', details: 'Roadmap-Knoten "Erstellen": Offen -> In Arbeit', isComment: true }
|
||||
const commentType = { serviceType: 'COMMENT', employeeName: 'Kenso Grimm', totalTime: 0, endDate: '20.09.2026', details: 'intern', isComment: false }
|
||||
assert.deepEqual(customerWorkEntries([comment, commentType], { includeUpdates: true }), [])
|
||||
})
|
||||
|
||||
test('Arbeit: Einträge aus getCustomerActivity (date statt endDate, Zeit als Text) werden verstanden', () => {
|
||||
const fromActivity = { serviceType: 'Remote', employeeName: 'Andrej Stevanovski', totalTime: '30', date: '18.09.2026', details: 'Demo-Version erstellt', isGit: false, pending: false }
|
||||
assert.deepEqual(customerWorkEntries([fromActivity]), [
|
||||
{ kind: 'online', label: 'Online erledigt', date: '2026-09-18', minutes: 30, person: 'Andrej Stevanovski', text: 'Demo-Version erstellt' },
|
||||
])
|
||||
})
|
||||
Reference in new issue
Block a user