Projektdetails und Fortschritt kundenverständlich

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

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

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

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

No files matched your search

+7 -1
View File
@@ -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
View File
@@ -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' })
}
+233
View File
@@ -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
}
+2 -1
View File
@@ -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 }
})