Files
Webklar-Kundenbereich/public/neu/website-details.js
T

178 lines
10 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 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.')))
}