Files
Webklar-Kundenbereich/server/services/customerProjectView.js
T

250 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*/
import { parseSupporters, supportMinutes, firstName } from './worksheetMinutes.js'
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
// Unterstuetzung: eigene Zeilen, je Person die Haelfte der Arbeitszeit (aufgerundet auf 15 min)
const supports = parseSupporters(sheet.supporters, sheet.$id)
.map((s) => ({ name: firstName(s.name), task: s.task, minutes: supportMinutes(minutes) }))
.filter((s) => s.minutes > 0)
const withSupports = (entry) => (supports.length ? { ...entry, supports } : entry)
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 ? withSupports({ kind, label, date, minutes, changes }) : null
}
return withSupports({ 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)))
if (entry.supports) prev.supports = [...(prev.supports || []), ...entry.supports]
continue
}
entries.push(entry)
}
return entries
}
/** Arbeitszeit gesamt fuer den Projektkopf: alle Schritte inkl. Unterstuetzung */
export function workTotalMinutes(entries = []) {
return entries.reduce(
(sum, e) => sum + (Number(e.minutes) || 0) + (e.supports || []).reduce((s, x) => s + (Number(x.minutes) || 0), 0),
0
)
}