/* 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; 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), zeit: letzteAktivitaet(auftrag) })) .sort((a, b) => a.rang - b.rang || 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 }) { const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false' }, 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)) }) 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-lesen' }, `${stufe.name}, ${zustandText(stufe)}`)))) } function baueZeitleiste(zeilen) { const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN) 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 }, h('ol', { class: 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))), rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen' }) : null) } function baueAuftragsBlock(auftrag, jetzt, extra) { const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {} const zeilen = zeitleistenZeilen(auftrag, jetzt) 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' }, auftrag.topic || 'Dein Auftrag'), 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) : 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) }