Files
Webklar-Kundenbereich/public/neu/auftrags-zeitleiste.js
T
ClaudeandClaude Sonnet 5.5 f3f8ac3777 Kundenansicht neu: doppelte Klasse .kn-nur-lesen (ansichten.css) durch die vorhandene .kn-nur-leser (huelle.css) ersetzt
Die Stufennamen im Fortschrittsbalken der Zeitleiste (nur für Vorlesegeräte) tragen jetzt .kn-nur-leser; die zweite,
gleichwertige Regel .kn-nur-lesen in ansichten.css ist gestrichen. Tests: DOM- und Quelltext-Test (keine Spur von
.kn-nur-lesen in Stil und Modul), Stil-Test sucht die Klassenregeln auch in huelle.css.

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

187 lines
12 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: „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.).
Je Auftrag aus GET /api/activity: Überschrift mit Status-Pille, schmaler Fortschrittsbalken, bei Rückfrage der Hinweis zum Chat und eine
kompakte Zeitleiste der Einträge. Die Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) sind reine Funktionen; gezeichnet wird
mit h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import.
Antwortform laut server/routes/customerData.js und services/customerProjectView.js:
{ number, topic, status: { label, tone, step }, createdAt, entries: [{ kind, label, date: 'JJJJ-MM-TT', minutes, person, text | changes }] }. */
import { h } from './dom.js'
import { K, berlinTag, zuAnsicht } from './ansicht-hilfen.js'
import { AUFTRAG_SCHRITTE, statusTon } from './website-details.js'
/** Die vier Abschnitte des Balkens (dieselben Namen wie der Fortschritt der Projektdetails). */
export const STUFEN = AUFTRAG_SCHRITTE
/** So viele Aufträge stehen offen, der Rest hinter „Ältere Aufträge anzeigen“. */
export const MAX_AUFTRAEGE = 5
/** So viele Zeilen der Zeitleiste stehen offen, der Rest hinter „mehr anzeigen“. */
export const MAX_ZEILEN = 3
const MAX_KURZTEXT = 90
/* ---------------------------------------------------------------- Fortschritt --- */
/** Die vier Abschnitte des Balkens: erreicht bis zum aktuellen Stand; Stufe 0 (pausiert, storniert) und Unbekanntes erreichen nichts. */
export function fortschritt(status) {
const schritt = Number(status?.step)
const gueltig = Number.isInteger(schritt) && schritt >= 1 && schritt <= STUFEN.length
return STUFEN.map((name, i) => ({ name, erreicht: gueltig && i < schritt, aktuell: gueltig && i === schritt - 1 }))
}
/** Braucht der Auftrag eine Info vom Kunden? Der Server meldet das mit dem Ton „waiting“ (Ticketstatus „awaiting“, „Wartet auf deine Rückmeldung“). */
export const braucheInfo = (auftrag) => auftrag?.status?.tone === 'waiting'
/* ---------------------------------------------------------- Reihenfolge, Kürzen --- */
const eintraegeVon = (auftrag) => (Array.isArray(auftrag?.entries) ? auftrag.entries.filter((e) => e && typeof e === 'object') : [])
// 'JJJJ-MM-TT' (auch als Anfang eines ISO-Zeitpunkts) → { j, m, t } ohne Zeitzonen-Umrechnung; sonst null
function tagTeile(wert) {
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(wert ?? ''))
if (!treffer) return null
const [j, m, t] = [Number(treffer[1]), Number(treffer[2]), Number(treffer[3])]
return m >= 1 && m <= 12 && t >= 1 && t <= 31 ? { j, m, t } : null
}
const zweistellig = (zahl) => String(zahl).padStart(2, '0')
const isoTag = (wert) => { const tag = tagTeile(wert); return tag ? `${tag.j}-${zweistellig(tag.m)}-${zweistellig(tag.t)}` : '' }
/** Zeitpunkt der jüngsten Aktivität (ms): der späteste Eintrag oder die Anlage des Auftrags; 0, wenn nichts lesbar ist. */
export function letzteAktivitaet(auftrag) {
const zeiten = [Date.parse(String(auftrag?.createdAt ?? '')), ...eintraegeVon(auftrag).map((e) => { const tag = isoTag(e.date); return tag ? Date.parse(`${tag}T00:00:00Z`) : NaN })]
return Math.max(0, ...zeiten.filter((zeit) => Number.isFinite(zeit)))
}
// 0 = laufend (auch pausiert), 1 = erledigt, 2 = storniert oder abgebrochen
const rang = (auftrag) => (auftrag?.status?.tone === 'done' ? 1 : auftrag?.status?.tone === 'stopped' ? 2 : 0)
/** Laufende Aufträge zuerst, dann erledigte, Storniertes zuletzt; unter den laufenden stehen die mit Rückfrage (braucheInfo) vor den übrigen, damit eine Frage
an den Kunden nie hinter „Ältere Aufträge anzeigen“ verschwindet. Innerhalb jeder Gruppe die jüngste Aktivität vorn, bei Gleichstand die Reihenfolge des Servers. */
export function sortiereAuftraege(tickets) {
const liste = Array.isArray(tickets) ? tickets.filter((t) => t && typeof t === 'object') : []
return liste
.map((auftrag, i) => ({ auftrag, i, rang: rang(auftrag), frage: braucheInfo(auftrag) ? 0 : 1, zeit: letzteAktivitaet(auftrag) }))
.sort((a, b) => a.rang - b.rang || a.frage - b.frage || b.zeit - a.zeit || a.i - b.i)
.map((e) => e.auftrag)
}
/** Die ersten n sichtbar, der Rest getrennt; die Eingabe bleibt unverändert. */
export function kuerze(liste, max) {
const alle = Array.isArray(liste) ? liste : []
return { sichtbar: alle.slice(0, max), rest: alle.slice(max) }
}
/** Sortierte Aufträge: höchstens max offen, der Rest als „älter“. */
export function gruppiereAuftraege(tickets, max = MAX_AUFTRAEGE) {
const { sichtbar, rest } = kuerze(sortiereAuftraege(tickets), max)
return { sichtbar, aeltere: rest }
}
/* ---------------------------------------------------------------- Zeitleiste --- */
/** 'TT.MM.' für einen Tag 'JJJJ-MM-TT'; fällt er nicht ins laufende Jahr, steht 'TT.MM.JJ' da. Unlesbares ergibt ''. */
export function datumKurz(wert, jetzt = new Date()) {
const tag = tagTeile(wert)
if (!tag) return ''
const laufend = berlinTag(jetzt)?.j ?? jetzt.getUTCFullYear()
const jahr = tag.j === laufend ? '' : zweistellig(tag.j % 100)
return `${zweistellig(tag.t)}.${zweistellig(tag.m)}.${jahr}`
}
// erste nichtleere Zeile, Leerraum zusammengezogen
const ersteZeile = (text) => String(text ?? '').split(/\r?\n/).map((zeile) => zeile.replace(/\s+/g, ' ').trim()).find(Boolean) ?? ''
function kuerzeText(text, max) {
if (text.length <= max) return text
const schnitt = text.slice(0, max)
const leer = schnitt.lastIndexOf(' ')
return `${(leer > max * 0.6 ? schnitt.slice(0, leer) : schnitt).replace(/[\s,;:.–-]+$/, '')}…`
}
/** Kurzer Text einer Zeile: die erste Änderung (mit „und n weitere“), sonst der freie Text der Arbeit, sonst die Art der Arbeit. Nie lang, nie ein Name. */
export function eintragsKurztext(eintrag) {
const aenderungen = Array.isArray(eintrag?.changes) ? eintrag.changes.map(ersteZeile).filter(Boolean) : []
if (aenderungen.length) {
const rest = aenderungen.length - 1
const erste = kuerzeText(aenderungen[0], MAX_KURZTEXT)
return rest ? `${erste} und ${rest} weitere ${rest === 1 ? 'Änderung' : 'Änderungen'}` : erste
}
return kuerzeText(ersteZeile(eintrag?.text), MAX_KURZTEXT) || ersteZeile(eintrag?.label) || 'Arbeit am Projekt'
}
/** Zeilen der Zeitleiste eines Auftrags, neueste zuerst; Einträge ohne lesbares Datum stehen unten. */
export function zeitleistenZeilen(auftrag, jetzt = new Date()) {
return eintraegeVon(auftrag)
.map((e, i) => ({ i, iso: isoTag(e.date), datum: datumKurz(e.date, jetzt), text: eintragsKurztext(e) }))
.sort((a, b) => (a.iso === b.iso ? a.i - b.i : !a.iso ? 1 : !b.iso ? -1 : b.iso.localeCompare(a.iso)))
.map(({ datum, iso, text }) => ({ datum, iso, text }))
}
/* ------------------------------------------------------------------------ DOM --- */
const LEER = 'Es wurden noch keine Arbeiten dokumentiert.'
const KEINE_EINTRAEGE = 'Noch keine Arbeiten dokumentiert.'
/** Knopf, der den nächsten Wrapper [data-einklappbar] auf- und zuklappt (is-open); die Zeilen dahinter tragen [data-extra]. Die CSS-Regeln dazu gelten nur für
direkte Kinder (ansichten.css, .kn-az-…): die allgemeine Regel .kn-einklappbar würde bei verschachtelten Wrappern auch offene Zeilen verbergen. */
function umschalter({ attribut, klasse, mehr, weniger, name, steuert }) {
// name: Zusatz für den Namen des Knopfes („mehr anzeigen zu „Titel““, der sichtbare Text steht vorn im Namen); steuert: Id der Liste, die er auf- und zuklappt
const nameFuer = (text) => (name ? `${text} zu „${name}“` : null)
const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false', 'aria-label': nameFuer(mehr), 'aria-controls': steuert || null }, mehr)
knopf.addEventListener('click', () => {
const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open')
knopf.textContent = offen ? weniger : mehr
knopf.setAttribute('aria-expanded', String(offen))
if (name) knopf.setAttribute('aria-label', nameFuer(offen ? weniger : mehr))
})
return knopf
}
const zustandText = (stufe) => (stufe.aktuell ? 'aktueller Stand' : stufe.erreicht ? 'erreicht' : 'noch offen')
// Vier Abschnitte, erreichte schwarz; die Namen stehen nur für Screenreader als Text. Ohne erreichte Stufe (pausiert, storniert) gibt es keinen Balken.
function baueBalken(status) {
const stufen = fortschritt(status)
if (!stufen.some((stufe) => stufe.erreicht)) return null
return h('ol', { class: 'kn-az-balken', 'aria-label': 'Fortschritt des Auftrags' }, stufen.map((stufe) => h('li',
{ class: stufe.erreicht ? 'is-done' : null, 'aria-current': stufe.aktuell ? 'step' : null },
h('span', { class: 'kn-nur-leser' }, `${stufe.name}, ${zustandText(stufe)}`))))
}
let listenNummer = 0 // Ids der Listen für aria-controls: eindeutig je Seite, auch wenn die Ansicht mehrmals gezeichnet wird
function baueZeitleiste(zeilen, titel) {
const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN)
listenNummer += 1
const listenId = `kn-az-zeilen-${listenNummer}`
const zeile = (z, extra) => h('li', { 'data-extra': extra },
z.datum ? h('time', { class: 'kn-az-datum', datetime: z.iso || null }, z.datum) : h('span', { class: 'kn-az-datum', 'aria-hidden': 'true' }),
h('span', { class: 'kn-az-text' }, z.text))
return h('div', { class: 'kn-az-zeitleiste', 'data-einklappbar': true },
// Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte aller Zeilen die Breite dafür, damit die Texte bündig bleiben
h('ol', { id: listenId, class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))),
// Mehrere Knöpfe „mehr anzeigen“ auf einer Seite: der Name nennt den Auftrag, aria-controls die Liste
rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen', name: titel, steuert: listenId }) : null)
}
function baueAuftragsBlock(auftrag, jetzt, extra) {
const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {}
const zeilen = zeitleistenZeilen(auftrag, jetzt)
const titel = auftrag.topic || 'Dein Auftrag'
return h('li', { class: 'kn-az', 'data-auftrag': auftrag.number || null, 'data-extra': extra },
h('div', { class: 'kn-az-kopf' },
h('h3', { class: 'kn-az-titel' }, titel),
status.label ? h('span', { class: K.pille[statusTon(status.tone)] }, status.label) : null),
// Die ganze Zeile führt in den Chat (große Tippfläche am Handy); unterstrichen ist nur „Im Chat antworten“
braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, h('a', zuAnsicht('chat'), 'Wir brauchen eine Info von dir · ', h('span', { class: 'kn-az-info-link' }, 'Im Chat antworten'))) : null,
baueBalken(status),
zeilen.length ? baueZeitleiste(zeilen, titel) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE))
}
/** „Was wir zuletzt erledigt haben“: ein Block je Auftrag; fünf offen, der Rest hinter „Ältere Aufträge anzeigen“. Ohne Aufträge ein ruhiger Satz. */
export function baueAktivitaet(tickets, jetzt = new Date()) {
const { sichtbar, aeltere } = gruppiereAuftraege(tickets)
if (!sichtbar.length) return h('p', { class: 'kn-hinweis' }, LEER)
return h('div', { class: 'kn-az-liste', 'data-einklappbar': true },
h('ul', { class: 'kn-az-auftraege' }, sichtbar.map((a) => baueAuftragsBlock(a, jetzt, false)), aeltere.map((a) => baueAuftragsBlock(a, jetzt, true))),
aeltere.length ? umschalter({ attribut: 'data-aeltere', klasse: 'kn-knopf kn-az-aeltere', mehr: 'Ältere Aufträge anzeigen', weniger: 'Weniger anzeigen' }) : null)
}