178 lines
10 KiB
JavaScript
178 lines
10 KiB
JavaScript
/* 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.')))
|
||
}
|