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