diff --git a/public/neu/ansicht-website.js b/public/neu/ansicht-website.js index 1911530..d80f4bf 100644 --- a/public/neu/ansicht-website.js +++ b/public/neu/ansicht-website.js @@ -4,7 +4,8 @@ import { api } from './api.js' import { h, leeren } from './dom.js' import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js' -import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js' +import { baueProjektDetails, ladeProjektDetails } from './website-details.js' +import { baueAktivitaet } from './auftrags-zeitleiste.js' import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js' import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js' import { HANDY_ABFRAGE } from './leiste.js' diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 7467b6a..6e459f9 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -85,10 +85,7 @@ .kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; } /* Auftrag, Änderungen, Arbeiten, Aktivität */ -.kn-aktivitaet { margin: 0; } [data-abschnitt="aktivitaet"] { scroll-margin-top: 16px; } /* #fortschritt: scrollIntoView lässt den Abschnitt nicht hinter dem haftenden Kopf beginnen (am Handy unten) */ -.kn-auftrag-karte { padding: 16px 0; border-top: 1px solid var(--kn-line); } -.kn-auftrag-karte > .kn-auftrag + * { margin-top: 12px; } .kn-auftrag-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .kn-auftrag-name { min-width: 0; } .kn-auftrag-nr { display: block; font-size: 12px; color: var(--kn-text-2); font-variant-numeric: tabular-nums; } @@ -113,6 +110,28 @@ .kn-eintrag-meta { font-size: 13px; color: var(--kn-text-2); } .kn-eintrag-text { margin: 4px 0 0; color: var(--kn-text-2); font-size: 13px; } .kn-unterstuetzung li { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; font-size: 13px; color: var(--kn-text-2); } +/* „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): je Auftrag Überschrift mit Pille, schmaler Balken in vier Abschnitten, Zeitleiste mit Haarlinie */ +.kn-az-liste { margin: 0; } +.kn-az-auftraege { list-style: none; margin: 0; padding: 0; } +.kn-az { padding: 20px 0; border-top: 1px solid var(--kn-line); } +.kn-az-kopf { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 6px 12px; } +.kn-az-titel { flex: 1 1 12em; min-width: 0; margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; } +.kn-az-kopf .kn-pille { flex: none; margin-top: 2px; } +.kn-az-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; color: var(--kn-orange-fg); } +.kn-az-info a { font-weight: 600; text-decoration-color: currentColor; } +.kn-az-balken { display: flex; gap: 3px; margin: 12px 0 0; padding: 0; list-style: none; } +.kn-az-balken li { position: relative; flex: 1; height: 4px; border-radius: 2px; background: var(--kn-line); } +.kn-az-balken li.is-done { background: var(--kn-accent); } +.kn-nur-lesen { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } +.kn-az-zeitleiste { margin: 14px 0 0; } +.kn-az-zeilen { list-style: none; margin: 0 0 0 3px; padding: 0; border-left: 1px solid var(--kn-line-strong); } +.kn-az-zeilen li { position: relative; display: flex; gap: 10px; padding: 4px 0 4px 16px; font-size: 14px; line-height: 20px; } +.kn-az-zeilen li::before { content: ""; position: absolute; left: -3px; top: 12px; width: 5px; height: 5px; border-radius: 50%; background: var(--kn-line-strong); } +.kn-az-datum { flex: none; min-width: 3.2em; color: var(--kn-text-2); font-variant-numeric: tabular-nums; white-space: nowrap; } +.kn-az-text { min-width: 0; overflow-wrap: anywhere; } +.kn-az-mehr { margin: 4px 0 0 8px; } +.kn-az-leer { margin: 12px 0 0; font-size: 14px; color: var(--kn-text-2); } +.kn-az-aeltere { margin: 8px 0 0; } .kn-dateien { margin: 0; } .kn-details { margin: 0; } @@ -357,6 +376,8 @@ .kn-zeilenlink { display: inline-flex; align-items: center; min-height: 44px; } /* Pflicht- und Zahlungslinks (E-Fix F3): Verträge hier kündigen, Vertrag widerrufen, Vertragsbedingungen, Zahlen in der Zeile „Rechnung offen“ */ .kn-kuendigen a, .kn-widerruf a, .kn-links a, .kn-rechnung-offen a { display: inline-flex; align-items: center; min-height: 44px; } + .kn-az-info a { display: inline-flex; align-items: center; min-height: 44px; } + .kn-az-info { margin-top: 4px; } .kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .kn-rechnung-offen .kn-trenner { display: none; } .kn-rechnung-offen a { flex: none; font-weight: 600; } diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js index bfa7e09..81baad7 100644 --- a/public/neu/auftrags-zeitleiste.js +++ b/public/neu/auftrags-zeitleiste.js @@ -1,11 +1,13 @@ /* 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. Dieser Teil ist reine Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) ohne DOM-Zugriff. + 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 { berlinTag } from './ansicht-hilfen.js' -import { AUFTRAG_SCHRITTE } from './website-details.js' +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 @@ -111,3 +113,61 @@ export function zeitleistenZeilen(auftrag, jetzt = new Date()) { .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]. */ +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 kn-einklappbar', '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), + braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, 'Wir brauchen eine Info von dir · ', h('a', zuAnsicht('chat'), '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 kn-einklappbar', '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) +} diff --git a/public/neu/website-details.js b/public/neu/website-details.js index 7d5994b..656fe14 100644 --- a/public/neu/website-details.js +++ b/public/neu/website-details.js @@ -1,4 +1,4 @@ -/* Kundenansicht neu (P2): Bausteine der Projektdetails und der Aktivität (Auftrag, Änderungen, Arbeiten, Dateien). +/* 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' @@ -8,7 +8,6 @@ import { K, zuAnsicht, fmtBytes, fmtMinuten, fmtUhrzeit, berlinTag } from './ans 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. */ @@ -136,24 +135,6 @@ export function baueDateien(files) { 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) diff --git a/test/knAnsichtWebsite.test.js b/test/knAnsichtWebsite.test.js index 26b2069..1ed3dfe 100644 --- a/test/knAnsichtWebsite.test.js +++ b/test/knAnsichtWebsite.test.js @@ -32,6 +32,22 @@ test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GE const w = await zeige(fx) assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]')) })) +const ZEIT_AUFTRAEGE = [ + { number: '100207', topic: 'Öffnungszeiten aktualisieren', createdAt: '2026-10-02T10:00:00.000Z', status: { label: 'Erledigt', tone: 'done', step: 4 }, entries: [{ kind: 'online', label: 'Online erledigt', date: '2026-10-04', minutes: 50, person: 'Lena Vogel', text: 'Neue Öffnungszeiten eingetragen.' }] }, + { number: '100231', topic: 'Neue Fotos für die Startseite', createdAt: '2026-10-13T10:00:00.000Z', status: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, entries: [] }, +] +test('Website: „Was wir zuletzt erledigt haben“ ist eine Zeitleiste, laufende Aufträge zuerst, Rückfrage mit Link in den Chat', mit({ ...HAUPT, 'GET /api/activity': { tickets: ZEIT_AUFTRAEGE } }, async (fx) => { + const w = await zeige(fx) + const abschnitt = fx.finde(w, '[data-abschnitt="aktivitaet"]') + assert.equal(fx.finde(abschnitt, 'h2').textContent, 'Was wir zuletzt erledigt haben') + assert.deepEqual(fx.alle(abschnitt, '[data-auftrag]').map((b) => b.getAttribute('data-auftrag')), ['100231', '100207']) + assert.equal(fx.finde(abschnitt, '.kn-az-info').textContent, 'Wir brauchen eine Info von dir · Im Chat antworten') + assert.equal(fx.finde(abschnitt, '.kn-az-info a').getAttribute('href'), '#chat') + assert.match(fx.finde(abschnitt, '[data-auftrag="100207"]').textContent, /04\.10\.Neue Öffnungszeiten eingetragen\./) +})) +test('Website: ohne Aufträge steht der ruhige Satz', mit(HAUPT, async (fx) => { + assert.match(fx.text(fx.finde(await zeige(fx), '[data-abschnitt="aktivitaet"]')), /Es wurden noch keine Arbeiten dokumentiert\./) +})) test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) })) test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => { const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/) diff --git a/test/knAnsichtWebsiteDetails.test.js b/test/knAnsichtWebsiteDetails.test.js index ca10d3d..3b02f4a 100644 --- a/test/knAnsichtWebsiteDetails.test.js +++ b/test/knAnsichtWebsiteDetails.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { - AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, baueAktivitaet, ladeProjektDetails, baueProjektDetails, + AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, ladeProjektDetails, baueProjektDetails, } from '../public/neu/website-details.js' import { mit, mitDom } from './support/knDaten.js' @@ -45,15 +45,7 @@ test('Auftrag: Nummer, Betreff, Status, Fortschritt, Hinweis bei Rückfrage', () assert.match(schlicht.textContent, /Dein Auftrag/); assert.equal(fx.alle(schlicht, 'li').length, 0); assert.doesNotMatch(schlicht.textContent, /Wir brauchen/) })) -test('Aktivität: neueste zuerst, drei sichtbar, „Alle anzeigen (n)“ klappt auf; fremde Inhalte bleiben Text', () => mitDom((fx) => { - const t = (n, createdAt, topic) => ({ number: String(n), topic, createdAt, status: { label: 'Offen', tone: 'active', step: 2 }, entries: [] }) - const el = baueAktivitaet([t(1, '2026-10-01T00:00:00Z', 'Eins'), t(4, '2026-10-04T00:00:00Z', ''), t(2, '2026-10-02T00:00:00Z', 'Zwei'), t(3, '2026-10-03T00:00:00Z', 'Drei')], new Date('2026-10-16T09:00:00Z')) - assert.equal(fx.alle(el, 'article').length, 3); assert.match(el.textContent, /Auftrag 4/); assert.doesNotMatch(el.textContent, /Auftrag 1/) - assert.equal(fx.finde(el, 'img'), null) - const alle = fx.finde(el, (e) => e.textContent === 'Alle anzeigen (4)'); assert.ok(alle) - fx.klick(alle); assert.equal(fx.alle(el, 'article').length, 4); assert.ok(fx.finde(el, (e) => e.tagName === 'BUTTON' && e.textContent === 'Weniger anzeigen')) - assert.equal(baueAktivitaet([], new Date()).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') -})) +// Die Aktivität der Website-Seite (Zeitleiste) prüfen knAuftragsZeitleiste.test.js und knAuftragsZeitleisteDom.test.js. test('Arbeiten: Unterstützung, Änderungsliste und Text; Zeitangaben', () => mitDom((fx) => { const el = baueArbeiten([{ date: '2026-09-20', kind: 'update', label: 'Aktualisierung', person: 'Kenso', minutes: 90, changes: ['Menü angepasst'], supports: [{ name: 'Justin', task: 'Bilder', minutes: 15 }] }, diff --git a/test/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js new file mode 100644 index 0000000..cf4ddaa --- /dev/null +++ b/test/knAuftragsZeitleisteDom.test.js @@ -0,0 +1,133 @@ +// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): der DOM-Bau mit dem Fake-DOM. Die reine Logik prüft knAuftragsZeitleiste.test.js. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js' +import { mitDom } from './support/knDaten.js' + +const JETZT = new Date('2026-10-16T09:00:00Z') +const ST = { + neu: { label: 'Eingegangen', tone: 'new', step: 1 }, + arbeit: { label: 'In Arbeit', tone: 'active', step: 2 }, + wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, + pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 }, + fertig: { label: 'Erledigt', tone: 'done', step: 4 }, + pause: { label: 'Pausiert', tone: 'paused', step: 0 }, + storniert: { label: 'Storniert', tone: 'stopped', step: 0 }, +} +const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries }) +const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over }) + +/* ---------------------------------------------------------------------- DOM --- */ + +const dreiAuftraege = () => [ + auftrag(100207, 'fertig', '2026-10-02T10:00:00Z', [eintrag('2026-10-04', 'Neue Öffnungszeiten eingetragen.')], 'Öffnungszeiten aktualisieren'), + auftrag(100231, 'wartet', '2026-10-13T10:00:00Z', [eintrag('2026-10-15', 'Fotos erhalten.'), eintrag('2026-10-13', 'Bildauswahl vorbereitet.')], 'Neue Fotos für die Startseite'), + auftrag(100224, 'pruefung', '2026-10-06T10:00:00Z', [ + eintrag('2026-10-14', 'Speisekarte gesetzt.'), eintrag('2026-10-13', 'Layout geprüft.'), eintrag('2026-10-12', 'Entwurf erstellt.'), eintrag('2026-10-09', 'Texte gesammelt.'), + ], 'Speisekarte als PDF'), +] + +test('Aktivität: ein Block je Auftrag, Titel als Überschrift, Pille in Farbe des Status, Reihenfolge laufend vor erledigt', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const bloecke = fx.alle(el, '[data-auftrag]') + assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['100231', '100224', '100207']) + const kopf = (b) => ({ titel: fx.finde(b, 'h3').textContent, pille: fx.finde(b, '.kn-pille').textContent, klasse: fx.finde(b, '.kn-pille').className }) + assert.deepEqual(kopf(bloecke[0]), { titel: 'Neue Fotos für die Startseite', pille: 'Wartet auf deine Rückmeldung', klasse: 'kn-pille kn-pille-orange' }) + assert.deepEqual(kopf(bloecke[1]), { titel: 'Speisekarte als PDF', pille: 'Wird geprüft', klasse: 'kn-pille kn-pille-violett' }) + assert.deepEqual(kopf(bloecke[2]), { titel: 'Öffnungszeiten aktualisieren', pille: 'Erledigt', klasse: 'kn-pille kn-pille-gruen' }) + assert.equal(fx.finde(bloecke[0], 'h3').className, 'kn-az-titel') +})) + +test('Aktivität: In Arbeit ist blau, Eingegangen und Pausiert grau, Storniert rot', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z'), auftrag(2, 'neu', '2026-10-04T00:00:00Z'), auftrag(3, 'pause', '2026-10-03T00:00:00Z'), auftrag(4, 'storniert', '2026-10-02T00:00:00Z')], JETZT) + assert.deepEqual(fx.alle(el, '.kn-pille').map((p) => p.className.split(' ')[1]), ['kn-pille-blau', 'kn-pille-grau', 'kn-pille-grau', 'kn-pille-rot']) +})) + +test('Aktivität: schmaler Balken mit vier Abschnitten, Stufennamen als Text für Screenreader', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]') + const stand = (b) => fx.alle(fx.finde(b, '.kn-az-balken'), 'li').map((li) => [li.classList.contains('is-done'), li.getAttribute('aria-current')]) + assert.deepEqual(stand(wartet), [[true, null], [true, 'step'], [false, null], [false, null]]) + assert.deepEqual(stand(pruefung), [[true, null], [true, null], [true, 'step'], [false, null]]) + assert.deepEqual(stand(fertig), [[true, null], [true, null], [true, null], [true, 'step']]) + const balken = fx.finde(wartet, '.kn-az-balken'); assert.equal(balken.tagName, 'OL'); assert.equal(balken.getAttribute('aria-label'), 'Fortschritt des Auftrags') + assert.deepEqual(fx.alle(balken, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, aktueller Stand', 'Prüfung, noch offen', 'Erledigt, noch offen']) + assert.deepEqual(fx.alle(fertig, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, erreicht', 'Prüfung, erreicht', 'Erledigt, aktueller Stand']) +})) + +test('Aktivität: Stufe 0 (pausiert, storniert) zeigt keinen Balken, nur die Pille', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'pause', '2026-10-05T00:00:00Z'), auftrag(2, 'storniert', '2026-10-04T00:00:00Z')], JETZT) + assert.equal(fx.alle(el, '[data-auftrag]').length, 2); assert.equal(fx.finde(el, '.kn-az-balken'), null) +})) + +test('Aktivität: Rückfrage steht unter der Überschrift mit Link in den Chat, sonst keine Zeile', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]') + const info = fx.finde(wartet, '.kn-az-info') + assert.equal(info.textContent, 'Wir brauchen eine Info von dir · Im Chat antworten') + const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Im Chat antworten', '#chat', 'chat']) + assert.equal(fx.alle(el, '.kn-az-info').length, 1); assert.equal(fx.finde(pruefung, '.kn-az-info'), null); assert.equal(fx.finde(fertig, '.kn-az-info'), null) + // Reihenfolge im Block: Kopf, Info, Balken, Zeitleiste + const klassen = [...wartet.children].map((k) => k.className.split(' ')[0]); assert.deepEqual(klassen, ['kn-az-kopf', 'kn-az-info', 'kn-az-balken', 'kn-az-zeitleiste']) +})) + +test('Aktivität: Auftragsnummer erscheint nicht als Text (ruhiger), bleibt aber als Datenattribut', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + assert.doesNotMatch(fx.text(el), /1002\d\d|Auftrag \d/) +})) + +test('Aktivität: Zeitleiste mit Datum und Kurztext, drei sichtbar, „mehr anzeigen“ klappt den Rest auf', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const pruefung = fx.alle(el, '[data-auftrag]')[1] + const zeilen = fx.alle(pruefung, '.kn-az-zeitleiste li') + assert.deepEqual(zeilen.map((z) => fx.finde(z, 'time').textContent), ['14.10.', '13.10.', '12.10.', '09.10.']) + assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-az-text').textContent), ['Speisekarte gesetzt.', 'Layout geprüft.', 'Entwurf erstellt.', 'Texte gesammelt.']) + assert.equal(fx.finde(zeilen[0], 'time').getAttribute('datetime'), '2026-10-14') + assert.deepEqual(zeilen.map((z) => z.hasAttribute('data-extra')), [false, false, false, true]) + const wrapper = fx.finde(pruefung, '[data-einklappbar]'); assert.ok(wrapper); assert.ok(!wrapper.classList.contains('is-open')) + const mehr = fx.finde(pruefung, '[data-mehr]'); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false') + fx.klick(mehr) + assert.ok(wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'weniger anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'true') + fx.klick(mehr) + assert.ok(!wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false') + // genau drei Zeilen: kein Knopf; weniger: kein Knopf + assert.equal(fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]'), null) + assert.equal(fx.alle(el, '[data-mehr]').length, 1) +})) + +test('Aktivität: Auftrag ohne Einträge zeigt einen ruhigen Satz statt einer leeren Zeitleiste', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [])], JETZT) + assert.equal(fx.finde(el, '.kn-az-zeitleiste'), null); assert.match(fx.text(el), /Noch keine Arbeiten dokumentiert\./) +})) + +test('Aktivität: fünf Aufträge sichtbar, „Ältere Aufträge anzeigen“ klappt den Rest auf', () => mitDom((fx) => { + const liste = Array.from({ length: 8 }, (_, i) => auftrag(200 + i, i === 7 ? 'fertig' : 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, [eintrag(`2026-10-0${i + 1}`, `Eintrag ${i}`)])) + const el = baueAktivitaet(liste, JETZT) + const bloecke = fx.alle(el, '[data-auftrag]') + assert.equal(bloecke.length, 8) + assert.deepEqual(bloecke.map((b) => b.hasAttribute('data-extra')), [false, false, false, false, false, true, true, true]) + assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['206', '205', '204', '203', '202', '201', '200', '207']) + const alt = fx.finde(el, '[data-aeltere]'); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'false') + const huelle = alt.closest('[data-einklappbar]'); assert.ok(!huelle.classList.contains('is-open')) + fx.klick(alt); assert.ok(huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Weniger anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'true') + fx.klick(alt); assert.ok(!huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen') + assert.equal(fx.finde(baueAktivitaet(liste.slice(0, 5), JETZT), '[data-aeltere]'), null) +})) + +test('Aktivität: Zeilen-Knopf und Aufträge-Knopf sind getrennt (mehr anzeigen öffnet nicht die älteren Aufträge)', () => mitDom((fx) => { + const viele = Array.from({ length: 6 }, (_, i) => auftrag(300 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t)))) + const el = baueAktivitaet(viele, JETZT) + const huelle = fx.finde(el, '[data-aeltere]').closest('[data-einklappbar]') + const ersterMehr = fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]') + fx.klick(ersterMehr); assert.ok(!huelle.classList.contains('is-open')); assert.ok(ersterMehr.closest('[data-einklappbar]').classList.contains('is-open')) +})) + +test('Aktivität: fremde Inhalte bleiben Text; leer und kaputte Elemente', () => mitDom((fx) => { + const boese = '' + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', boese), eintrag('2026-10-05', 'x', { changes: [boese] })], boese)], JETZT) + assert.equal(fx.finde(el, 'img'), null); assert.ok(fx.text(el).includes(boese)) + assert.equal(baueAktivitaet([], JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') + assert.equal(baueAktivitaet(undefined, JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') + const roh = baueAktivitaet([{ number: '9' }, { topic: 'Nur Titel', status: null, entries: null }], JETZT) + assert.equal(fx.alle(roh, '.kn-az').length, 2); assert.match(fx.text(roh), /Dein Auftrag/); assert.doesNotMatch(fx.text(roh), /null|undefined/) +}))