/* Kundenansicht neu (P2): Bausteine der Projektdetails und der Aktivität (Auftrag, Änderungen, Arbeiten, Dateien). Alles über h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. */ import { api } from './api.js' import { h } from './dom.js' import { K, zuAnsicht, fmtBytes, fmtMinuten, fmtUhrzeit, berlinTag } from './ansicht-hilfen.js' export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'] const ANZAHL_AENDERUNGEN = 5 const ANZAHL_EINTRAEGE = 4 const ANZAHL_AKTIVITAET = 3 const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' } /** Farbton der Status-Pille zum Ton des Servers. */ export const statusTon = (ton) => TON[ton] ?? 'blau' const tagNummer = (b) => Math.floor(Date.UTC(b.j, b.m - 1, b.t) / 86400000) /** 'Heute' | 'Gestern' | 'Mittwoch, 14. Oktober' (mit Jahr, wenn es nicht das laufende ist); Kalendertag in Berliner Zeit. */ export function tagesUeberschrift(datum, jetzt = new Date()) { const tag = berlinTag(datum) const heute = berlinTag(jetzt) if (!tag || !heute) return '' const diff = tagNummer(heute) - tagNummer(tag) if (diff === 0) return 'Heute' if (diff === 1) return 'Gestern' return new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', weekday: 'long', day: 'numeric', month: 'long', ...(tag.j === heute.j ? {} : { year: 'numeric' }), }).format(datum) } // '2026-09-20' → '20.09.2026' (reines Datum ohne Zeitzonen-Umrechnung) function isoTag(tag) { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(tag || '')) return m ? `${m[3]}.${m[2]}.${m[1]}` : '' } const leerText = (text) => h('p', { class: 'kn-hinweis' }, text) /** Knopf "n weitere … anzeigen" / "Weniger anzeigen"; schaltet is-open am Wrapper [data-einklappbar]. */ function mehrKnopf(gesamt, sichtbar, [eins, viele]) { const rest = gesamt - sichtbar if (rest <= 0) return null const beschriftung = `${rest} ${rest === 1 ? eins : viele} anzeigen` const knopf = h('button', { type: 'button', class: 'kn-textknopf kn-mehr', 'data-mehr': true }, beschriftung) knopf.addEventListener('click', () => { const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open') knopf.textContent = offen ? 'Weniger anzeigen' : beschriftung }) return knopf } const einklappbar = (...kinder) => h('div', { class: 'kn-einklappbar', 'data-einklappbar': true }, ...kinder) /** Auftrag: Nummer, Betreff, Status, Fortschritt Eingegangen → Erledigt, bei Rückfrage der Hinweis zum Chat. */ export function baueAuftrag(order, gesamtMinuten = 0) { const status = order?.status ?? { label: '', tone: 'new', step: 0 } const schritte = status.step > 0 ? h('ol', { class: 'kn-schritte', 'aria-label': 'Fortschritt des Auftrags' }, AUFTRAG_SCHRITTE.map((label, i) => { const n = i + 1 return h('li', { class: n < status.step ? 'is-done' : n === status.step ? 'is-current' : null, 'aria-current': n === status.step ? 'step' : null }, label) })) : null const hinweis = status.tone === 'waiting' ? h('p', { class: 'kn-auftrag-hinweis' }, 'Wir brauchen noch eine Info von dir. ', h('a', zuAnsicht('chat'), 'Im Chat antworten')) : null return h('div', { class: `kn-auftrag kn-ton-${statusTon(status.tone)}` }, h('div', { class: 'kn-auftrag-kopf' }, h('div', { class: 'kn-auftrag-name' }, order?.number ? h('span', { class: 'kn-auftrag-nr' }, `Auftrag ${order.number}`) : null, h('h3', null, order?.topic || 'Dein Auftrag')), h('span', { class: K.pille[statusTon(status.tone)] }, status.label)), gesamtMinuten > 0 ? h('p', { class: 'kn-auftrag-gesamt' }, 'Arbeitszeit gesamt: ', h('strong', null, fmtMinuten(gesamtMinuten))) : null, schritte, hinweis) } /** Letzte Änderungen nach Tagen gruppiert; die ersten fünf sind sichtbar. */ export function baueAenderungen(changes, jetzt = new Date()) { const liste = Array.isArray(changes) ? changes : [] if (!liste.length) return leerText('Noch keine Änderungen an der Website.') const gruppen = [] liste.forEach((c, i) => { const datum = new Date(c.date) const tag = Number.isNaN(datum.getTime()) ? '' : tagesUeberschrift(datum, jetzt) if (!gruppen.length || gruppen.at(-1).tag !== tag) gruppen.push({ tag, erster: i, eintraege: [] }) gruppen.at(-1).eintraege.push({ c, i }) }) const zeile = ({ c, i }, ganzeGruppeVersteckt) => { const zeit = fmtUhrzeit(c.date) return h('li', { class: 'kn-aenderung', 'data-extra': !ganzeGruppeVersteckt && i >= ANZAHL_AENDERUNGEN }, h('div', { class: 'kn-aenderung-kopf' }, h('strong', null, c.title), zeit ? h('time', { datetime: c.date }, zeit) : null), c.details?.length ? h('ul', { class: 'kn-aenderung-details' }, c.details.map((t) => h('li', null, t))) : null) } return einklappbar( h('div', { class: 'kn-zeitleiste' }, gruppen.map((g) => { const versteckt = g.erster >= ANZAHL_AENDERUNGEN return h('div', { class: 'kn-tag', 'data-extra': versteckt }, g.tag ? h('p', { class: 'kn-tag-name' }, g.tag) : null, h('ul', { class: 'kn-aenderungen' }, g.eintraege.map((e) => zeile(e, versteckt)))) })), mehrKnopf(liste.length, ANZAHL_AENDERUNGEN, ['weitere Änderung', 'weitere Änderungen'])) } function arbeitsZeile(e, extra) { const meta = [isoTag(e.date), e.person].filter(Boolean).join(' · ') const koerper = e.changes?.length ? h('ul', { class: 'kn-eintrag-aenderungen' }, e.changes.map((t) => h('li', null, t))) : e.text ? h('p', { class: 'kn-eintrag-text' }, e.text) : null return h('li', { class: `kn-eintrag kn-art-${String(e.kind || 'work').replace(/[^a-z-]/gi, '')}`, 'data-extra': extra }, h('div', { class: 'kn-eintrag-haupt' }, h('div', { class: 'kn-eintrag-kopf' }, h('strong', null, e.label), meta ? h('span', { class: 'kn-eintrag-meta' }, meta) : null), koerper, e.supports?.length ? h('ul', { class: 'kn-unterstuetzung' }, e.supports.map((x) => h('li', null, h('span', null, `↳ Unterstützung ${x.name}: ${x.task}`), h('span', { class: 'kn-zeit', title: 'Angerechnete Zeit der Unterstützung' }, fmtMinuten(x.minutes))))) : null), e.minutes ? h('span', { class: 'kn-zeit', title: 'Arbeitszeit' }, fmtMinuten(e.minutes)) : null) } /** Arbeiten am Projekt; vier sichtbar, der Rest hinter „weitere Einträge“. */ export function baueArbeiten(entries, leerTextWert) { const liste = Array.isArray(entries) ? entries : [] if (!liste.length) return leerText(leerTextWert) return einklappbar( h('ul', { class: 'kn-arbeiten' }, liste.map((e, i) => arbeitsZeile(e, i >= ANZAHL_EINTRAEGE))), mehrKnopf(liste.length, ANZAHL_EINTRAEGE, ['weiteren Eintrag', 'weitere Einträge'])) } /** Datei-Übersicht: Summe und Tabelle der obersten Ebene (Ordner mit Schrägstrich). */ export function baueDateien(files) { if (!files || !files.totalFiles) return leerText('Keine Datei-Informationen verfügbar.') const zeilen = (files.topLevel || []).map((eintrag) => h('tr', null, h('td', null, eintrag.type === 'dir' ? `${eintrag.path}/` : eintrag.path), h('td', { class: 'kn-num' }, eintrag.fileCount), h('td', { class: 'kn-num' }, fmtBytes(eintrag.sizeBytes)))) return h('div', { class: 'kn-dateien' }, h('p', { class: 'kn-notiz' }, `${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}`), h('div', { class: 'kn-tabelle' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Ordner / Datei'), h('th', null, 'Dateien'), h('th', null, 'Größe'))), h('tbody', null, zeilen)))) } /** Aktivität: je Auftrag Kopf und Arbeiten, die drei neuesten sichtbar, „Alle anzeigen (n)“ zeigt den Rest. */ export function baueAktivitaet(tickets, jetzt = new Date()) { const liste = Array.isArray(tickets) ? tickets : [] if (!liste.length) return leerText('Es wurden noch keine Arbeiten dokumentiert.') const sortiert = [...liste].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))) const behaelter = h('div', { class: 'kn-aktivitaet' }) const zeichne = (alle) => { const knopf = sortiert.length > ANZAHL_AKTIVITAET ? h('button', { type: 'button', class: K.knopf, on: { click: () => zeichne(!alle) } }, alle ? 'Weniger anzeigen' : `Alle anzeigen (${sortiert.length})`) : null behaelter.replaceChildren( ...sortiert.slice(0, alle ? sortiert.length : ANZAHL_AKTIVITAET).map((t) => h('article', { class: 'kn-auftrag-karte' }, baueAuftrag(t), baueArbeiten(t.entries, 'Noch keine Arbeiten dokumentiert.'))), ...(knopf ? [knopf] : [])) } zeichne(false) return behaelter } /** Die drei Quellen der Projektdetails; jede einzeln, ein Fehler ergibt null. */ export async function ladeProjektDetails(projectId, apiFn = api) { const id = encodeURIComponent(projectId) const [git, files, work] = await Promise.all(['git', 'files', 'work'].map((teil) => apiFn(`/api/projects/${id}/${teil}`).catch(() => null))) return { git, files, work } } const abschnitt = (titel, inhalt) => h('section', { class: 'kn-sektion' }, h('h2', { class: K.abschnitt }, titel), inhalt) /** Auftrag, Änderungen, Arbeiten und Dateien; fehlt eine Quelle, ersetzt ein Fehlertext nur ihren Abschnitt. */ export function baueProjektDetails({ git, files, work }, jetzt = new Date()) { const order = work?.order return h('div', { class: 'kn-details' }, order ? h('section', { class: 'kn-sektion' }, baueAuftrag(order, work?.totalMinutes || 0)) : null, abschnitt('Letzte Änderungen', git ? baueAenderungen(git.changes || [], jetzt) : leerText('Die Änderungen können gerade nicht geladen werden.')), // Der Abschnitt steht immer da (Test Etage projekt): ohne Auftrag zeigt er den Leertext, bei Ausfall den Fehlertext abschnitt('Arbeiten am Projekt', work ? baueArbeiten(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.') : leerText('Die Arbeiten können gerade nicht geladen werden.')), abschnitt('Dateien', files ? baueDateien(files) : leerText('Die Datei-Übersicht kann gerade nicht geladen werden.'))) }