Files
Webklar-Kundenbereich/public/neu/website-details.js
T
ClaudeandClaude Sonnet 5.5 2064bddba6 Kundenansicht neu (Aufträge): "Was wir zuletzt erledigt haben" als Zeitleiste auf der Website-Seite
Je Auftrag eine Überschrift (Titel fett, Status-Pille), ein schmaler Balken mit vier
Abschnitten (Stufennamen als Text für Screenreader), bei Rückfrage die Zeile "Wir brauchen
eine Info von dir · Im Chat antworten" und eine Zeitleiste mit Haarlinie (Datum TT.MM. und
Kurztext, drei sichtbar, "mehr anzeigen"). Fünf Aufträge offen, der Rest hinter
"Ältere Aufträge anzeigen". Die Auftragsnummer steht nicht mehr im Text (ruhiger), bleibt
als data-auftrag. Die alte baueAktivitaet und ihre toten CSS-Regeln entfallen; die
Projektdetails (baueAuftrag, baueArbeiten) bleiben unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:07:34 +00:00

159 lines
9.0 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 (Auftrag, Änderungen, Arbeiten, Dateien); die Aktivität der Website-Seite steht in auftrags-zeitleiste.js.
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 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))))
}
/** 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.')))
}