From f231f4b88796d8c692fe8186f9d146ce220c383d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:05:48 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Kundenansicht=20neu=20(Auftr=C3=A4ge):=20Lo?= =?UTF-8?q?gik=20der=20Zeitleiste=20(Fortschritt,=20Reihenfolge,=20Gruppie?= =?UTF-8?q?rung,=20K=C3=BCrzen,=20Kurztexte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reine Funktionen in public/neu/auftrags-zeitleiste.js: laufende Aufträge vor erledigten (jeweils jüngste Aktivität zuerst, Storniertes zuletzt), höchstens fünf Aufträge offen, vier Fortschrittsabschnitte aus status.step, Zeilen der Zeitleiste neueste zuerst mit Datum TT.MM. und Kurztext. Test zuerst (rot: Modul fehlte), dann grün. Co-Authored-By: Claude Sonnet 5.5 --- public/neu/auftrags-zeitleiste.js | 113 +++++++++++++++++++++++++ test/knAuftragsZeitleiste.test.js | 132 ++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 public/neu/auftrags-zeitleiste.js create mode 100644 test/knAuftragsZeitleiste.test.js diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js new file mode 100644 index 0000000..bfa7e09 --- /dev/null +++ b/public/neu/auftrags-zeitleiste.js @@ -0,0 +1,113 @@ +/* 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. + 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' + +/** 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 })) +} diff --git a/test/knAuftragsZeitleiste.test.js b/test/knAuftragsZeitleiste.test.js new file mode 100644 index 0000000..a2d0268 --- /dev/null +++ b/test/knAuftragsZeitleiste.test.js @@ -0,0 +1,132 @@ +// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.): reine Logik (Fortschritt, Reihenfolge, Gruppierung, Kürzen). +// Antwortform von GET /api/activity laut server/routes/customerData.js und services/customerProjectView.js. Der DOM-Bau steht in knAuftragsZeitleisteDom.test.js. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + MAX_AUFTRAEGE, MAX_ZEILEN, STUFEN, fortschritt, braucheInfo, letzteAktivitaet, sortiereAuftraege, gruppiereAuftraege, kuerze, + eintragsKurztext, datumKurz, zeitleistenZeilen, +} from '../public/neu/auftrags-zeitleiste.js' +import { AUFTRAG_SCHRITTE } from '../public/neu/website-details.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 }) +const nummern = (liste) => liste.map((a) => a.number) + +/* ------------------------------------------------------------------ reine Logik --- */ + +test('Stufen: dieselben vier Namen wie der Fortschritt der Projektdetails', () => { + assert.deepEqual(STUFEN, ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']) + assert.deepEqual(STUFEN, AUFTRAG_SCHRITTE) +}) + +test('Fortschritt: erreichte Abschnitte bis zum aktuellen, Stufe 0 ist leer', () => { + const stand = (step) => fortschritt({ step }).map((s) => [s.erreicht, s.aktuell]) + assert.deepEqual(stand(1), [[true, true], [false, false], [false, false], [false, false]]) + assert.deepEqual(stand(2), [[true, false], [true, true], [false, false], [false, false]]) + assert.deepEqual(stand(3), [[true, false], [true, false], [true, true], [false, false]]) + assert.deepEqual(stand(4), [[true, false], [true, false], [true, false], [true, true]]) + assert.deepEqual(fortschritt({ step: 4 }).map((s) => s.name), STUFEN) + for (const leer of [0, undefined, null, 'x', -1, 9]) assert.deepEqual(fortschritt({ step: leer }).map((s) => s.erreicht), [false, false, false, false], `step ${String(leer)}`) + assert.deepEqual(fortschritt(null).map((s) => s.erreicht), [false, false, false, false]) +}) + +test('Info vom Kunden nötig: genau der Ton „waiting“ (Status „awaiting“ des Servers)', () => { + assert.equal(braucheInfo(auftrag(1, 'wartet', '2026-10-01T00:00:00Z')), true) + for (const st of ['neu', 'arbeit', 'pruefung', 'fertig', 'pause', 'storniert']) assert.equal(braucheInfo(auftrag(1, st, '2026-10-01T00:00:00Z')), false, st) + assert.equal(braucheInfo({}), false); assert.equal(braucheInfo(null), false) +}) + +test('Letzte Aktivität: der jüngste Eintrag oder die Anlage des Auftrags, je nachdem, was später ist', () => { + const a = auftrag(1, 'arbeit', '2026-10-01T10:00:00Z', [eintrag('2026-10-05', 'x'), eintrag('2026-10-09', 'y'), eintrag('', 'ohne Datum')]) + assert.equal(letzteAktivitaet(a), Date.parse('2026-10-09T00:00:00Z')) + assert.equal(letzteAktivitaet(auftrag(2, 'arbeit', '2026-10-12T10:00:00Z', [eintrag('2026-10-05', 'x')])), Date.parse('2026-10-12T10:00:00Z')) + assert.equal(letzteAktivitaet(auftrag(3, 'arbeit', '', [])), 0) + assert.equal(letzteAktivitaet({ entries: 'kaputt', createdAt: 'nie' }), 0) +}) + +test('Reihenfolge: laufende zuerst (letzte Aktivität), dann erledigte (neueste zuerst), Storniertes ganz hinten', () => { + const liste = [ + auftrag(1, 'fertig', '2026-09-01T00:00:00Z', [eintrag('2026-10-14', 'fertig, aber jüngster Eintrag')]), + auftrag(2, 'arbeit', '2026-10-01T00:00:00Z', [eintrag('2026-10-03', 'alt')]), + auftrag(3, 'storniert', '2026-10-15T00:00:00Z'), + auftrag(4, 'wartet', '2026-10-02T00:00:00Z', [eintrag('2026-10-13', 'neuer')]), + auftrag(5, 'fertig', '2026-09-20T00:00:00Z', [eintrag('2026-09-25', 'älter')]), + auftrag(6, 'pause', '2026-10-04T00:00:00Z'), + auftrag(7, 'pruefung', '2026-10-05T00:00:00Z'), + auftrag(8, 'neu', '2026-10-12T00:00:00Z'), + ] + assert.deepEqual(nummern(sortiereAuftraege(liste)), ['4', '8', '7', '6', '2', '1', '5', '3']) + assert.deepEqual(nummern(liste), ['1', '2', '3', '4', '5', '6', '7', '8'], 'die Eingabe bleibt unverändert') +}) + +test('Reihenfolge: bei gleicher Aktivität bleibt die Reihenfolge des Servers', () => { + const liste = ['9', '8', '7'].map((n) => auftrag(n, 'arbeit', '2026-10-10T00:00:00Z')) + assert.deepEqual(nummern(sortiereAuftraege(liste)), ['9', '8', '7']) +}) + +test('Gruppierung: höchstens fünf Aufträge sichtbar, der Rest ist „älter“', () => { + assert.equal(MAX_AUFTRAEGE, 5) + const liste = Array.from({ length: 7 }, (_, i) => auftrag(100 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`)) + const g = gruppiereAuftraege(liste) + assert.deepEqual(nummern(g.sichtbar), ['106', '105', '104', '103', '102']); assert.deepEqual(nummern(g.aeltere), ['101', '100']) + const fuenf = gruppiereAuftraege(liste.slice(0, 5)); assert.equal(fuenf.sichtbar.length, 5); assert.deepEqual(fuenf.aeltere, []) + assert.deepEqual(gruppiereAuftraege([]), { sichtbar: [], aeltere: [] }); assert.deepEqual(gruppiereAuftraege(undefined), { sichtbar: [], aeltere: [] }) + assert.equal(gruppiereAuftraege(liste, 2).aeltere.length, 5) +}) + +test('Gruppierung: ein erledigter Auftrag rutscht hinter laufende, auch wenn er neuer ist', () => { + const liste = [auftrag(1, 'fertig', '2026-10-15T00:00:00Z'), ...Array.from({ length: 5 }, (_, i) => auftrag(10 + i, 'arbeit', `2026-09-0${i + 1}T00:00:00Z`))] + const g = gruppiereAuftraege(liste) + assert.deepEqual(nummern(g.aeltere), ['1']) +}) + +test('Kürzen: die ersten n sichtbar, der Rest getrennt; Eingabe bleibt', () => { + assert.equal(MAX_ZEILEN, 3) + const a = [1, 2, 3, 4, 5] + assert.deepEqual(kuerze(a, 3), { sichtbar: [1, 2, 3], rest: [4, 5] }) + assert.deepEqual(kuerze([1, 2], 3), { sichtbar: [1, 2], rest: [] }) + assert.deepEqual(kuerze(undefined, 3), { sichtbar: [], rest: [] }) + assert.deepEqual(a, [1, 2, 3, 4, 5]) +}) + +test('Datum: TT.MM., mit Jahr nur wenn es nicht das laufende ist', () => { + assert.equal(datumKurz('2026-10-02', JETZT), '02.10.') + assert.equal(datumKurz('2025-12-24', JETZT), '24.12.25') + assert.equal(datumKurz('2026-10-02T08:00:00.000Z', JETZT), '02.10.') + for (const kaputt of ['', null, undefined, 'gestern', '2026-13']) assert.equal(datumKurz(kaputt, JETZT), '', String(kaputt)) +}) + +test('Kurztext: Änderungen, freier Text oder Art der Arbeit; nie lang', () => { + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst'] }), 'Menü angepasst') + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst', 'Bilder getauscht'] }), 'Menü angepasst und 1 weitere Änderung') + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['A', 'B', 'C', 'D'] }), 'A und 3 weitere Änderungen') + assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Neue Öffnungszeiten eingetragen.' }), 'Neue Öffnungszeiten eingetragen.') + assert.equal(eintragsKurztext({ kind: 'onsite', label: 'Vor Ort bei dir', text: '' }), 'Vor Ort bei dir') + assert.equal(eintragsKurztext({ kind: 'work', label: '', text: '' }), 'Arbeit am Projekt') + assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Erste Zeile\n\nZweite Zeile mit Lücken' }), 'Erste Zeile') + assert.equal(eintragsKurztext({ kind: 'online', label: 'x', text: ' viel Leerraum ' }), 'viel Leerraum') + const lang = eintragsKurztext({ kind: 'online', label: 'x', text: 'Wort '.repeat(60) }) + assert.ok(lang.length <= 91 && lang.endsWith('…') && !lang.includes(' '), lang) + assert.equal(eintragsKurztext(null), 'Arbeit am Projekt') +}) + +test('Zeitleiste: neueste zuerst, Datum TT.MM., ohne Datum ganz unten, keine Namen und Minuten', () => { + const a = auftrag(1, 'arbeit', '2026-10-01T00:00:00Z', [ + eintrag('2026-10-03', 'Alt'), eintrag('2026-10-12', 'Neu', { person: 'Kenso', minutes: 90 }), eintrag('', 'Ohne Datum'), eintrag('2026-10-07', 'Mitte'), + ]) + const zeilen = zeitleistenZeilen(a, JETZT) + assert.deepEqual(zeilen.map((z) => [z.datum, z.text]), [['12.10.', 'Neu'], ['07.10.', 'Mitte'], ['03.10.', 'Alt'], ['', 'Ohne Datum']]) + assert.deepEqual(Object.keys(zeilen[0]).sort(), ['datum', 'iso', 'text']) + assert.equal(zeilen[0].iso, '2026-10-12') + assert.deepEqual(zeitleistenZeilen({ entries: null }, JETZT), []); assert.deepEqual(zeitleistenZeilen(null, JETZT), []) +}) From 2064bddba647ecfec3f51958e8f72db2672f7cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:07:34 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Kundenansicht=20neu=20(Auftr=C3=A4ge):=20"W?= =?UTF-8?q?as=20wir=20zuletzt=20erledigt=20haben"=20als=20Zeitleiste=20auf?= =?UTF-8?q?=20der=20Website-Seite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/neu/ansicht-website.js | 3 +- public/neu/ansichten.css | 27 +++++- public/neu/auftrags-zeitleiste.js | 66 ++++++++++++- public/neu/website-details.js | 21 +---- test/knAnsichtWebsite.test.js | 16 ++++ test/knAnsichtWebsiteDetails.test.js | 12 +-- test/knAuftragsZeitleisteDom.test.js | 133 +++++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 test/knAuftragsZeitleisteDom.test.js 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/) +})) From d62f8b805ec9d4e5c3972f3aaabf5bcbae321dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:28:43 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Kundenansicht=20neu=20(Auftr=C3=A4ge):=20R?= =?UTF-8?q?=C3=BCckfrage-Zeile=20als=20ganze=20Link-Zeile,=20Einklappen=20?= =?UTF-8?q?ohne=20verschachtelte=20Regel,=20Balken=20im=20Pseudo-Element?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Die Zeile "Wir brauchen eine Info von dir · Im Chat antworten" ist ein Link (#chat) mit großer Tippfläche am Handy; unterstrichen ist nur der zweite Teil. - Browserlauf: .kn-einklappbar:not(.is-open) [data-extra] verbarg die Zeilen einer aufgeklappten Zeitleiste, wenn der äußere Wrapper zu war. Die Wrapper haben jetzt eigene Klassen und Regeln nur für direkte Kinder (mit Regressionstest). - Die Fläche der Balken-Abschnitte liegt im Pseudo-Element, der Lesetext darunter bleibt auf Weiß (Gate low-contrast). - Das Modul steht in den Listen der Quelltext- und Stil-Tests der Ansicht-Module. Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansichten.css | 17 ++++++++----- public/neu/auftrags-zeitleiste.js | 10 +++++--- test/knAnsichtQuelltext.test.js | 2 +- test/knAnsichtStil.test.js | 2 +- test/knAuftragsZeitleisteDom.test.js | 38 +++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 6e459f9..9dc8d3b 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -112,16 +112,19 @@ .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-liste:not(.is-open) > .kn-az-auftraege > [data-extra] { display: none; } /* nur direkte Kinder: die Zeitleisten darin klappen für sich */ .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-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; } +.kn-az-info a { color: var(--kn-orange-fg); text-decoration: none; } +.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; } .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-az-balken li { position: relative; flex: 1; height: 4px; } +.kn-az-balken li::before { content: ""; position: absolute; inset: 0; border-radius: 2px; background: var(--kn-line); } /* die Fläche liegt im Pseudo-Element: der Lesetext darunter bleibt auf Weiß */ +.kn-az-balken li.is-done::before { 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); } @@ -129,6 +132,7 @@ .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-zeitleiste:not(.is-open) > .kn-az-zeilen > [data-extra] { display: none; } .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; } @@ -376,8 +380,9 @@ .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-az-info { margin-top: 0; } + .kn-az-info a { display: flex; align-items: center; min-height: 44px; } + .kn-az-info + .kn-az-balken { margin-top: 0; } .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 81baad7..2f2357c 100644 --- a/public/neu/auftrags-zeitleiste.js +++ b/public/neu/auftrags-zeitleiste.js @@ -119,7 +119,8 @@ export function zeitleistenZeilen(auftrag, jetzt = new Date()) { 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]. */ +/** 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', () => { @@ -146,7 +147,7 @@ function baueZeitleiste(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 }, + 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) } @@ -158,7 +159,8 @@ function baueAuftragsBlock(auftrag, jetzt, 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, + // 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)) } @@ -167,7 +169,7 @@ function baueAuftragsBlock(auftrag, jetzt, extra) { 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 }, + 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) } diff --git a/test/knAnsichtQuelltext.test.js b/test/knAnsichtQuelltext.test.js index 30a27a3..2e1d669 100644 --- a/test/knAnsichtQuelltext.test.js +++ b/test/knAnsichtQuelltext.test.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const NEU = path.join(WURZEL, 'public', 'neu') -const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] +const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] // Seit P7 sprechen auch die vier Bestellseiten „du“ (Fassung 6, C14); einzige Ausnahme ist der Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt) const WORTLAUT = [] const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine'] diff --git a/test/knAnsichtStil.test.js b/test/knAnsichtStil.test.js index 6ebcd90..6d23fc4 100644 --- a/test/knAnsichtStil.test.js +++ b/test/knAnsichtStil.test.js @@ -6,7 +6,7 @@ import { K } from '../public/neu/ansicht-hilfen.js' const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8') const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8') const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css') -const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] +const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url)) const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '') const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i diff --git a/test/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js index cf4ddaa..46526ac 100644 --- a/test/knAuftragsZeitleisteDom.test.js +++ b/test/knAuftragsZeitleisteDom.test.js @@ -1,8 +1,10 @@ // „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 { readFileSync } from 'node:fs' import { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js' import { mitDom } from './support/knDaten.js' +import { customerOrder, customerWorkEntries } from '../server/services/customerProjectView.js' const JETZT = new Date('2026-10-16T09:00:00Z') const ST = { @@ -65,7 +67,9 @@ test('Aktivität: Rückfrage steht unter der Überschrift mit Link in den Chat, 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']) + // Die ganze Zeile ist der Link in den Chat (eine große Tippfläche am Handy); nur „Im Chat antworten“ ist unterstrichen + const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Wir brauchen eine Info von dir · Im Chat antworten', '#chat', 'chat']) + assert.equal(fx.finde(chat, '.kn-az-info-link').textContent, 'Im Chat antworten'); assert.equal(fx.alle(info, 'a').length, 1) 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']) @@ -131,3 +135,35 @@ test('Aktivität: fremde Inhalte bleiben Text; leer und kaputte Elemente', () => 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/) })) + +// Vertrag mit dem Server: GET /api/activity baut jeden Auftrag aus customerOrder() und customerWorkEntries() (server/routes/customerData.js). +// Hier läuft die echte Server-Aufbereitung in die Zeitleiste, damit eine geänderte Antwortform den Test rot macht. +test('Vertrag: Aufträge in der Form von /api/activity (customerOrder, customerWorkEntries) erscheinen mit Datum und Kurztext', () => mitDom((fx) => { + const ticket = (woid, status, createdAt, worksheets) => ({ woid, topic: `Thema ${woid}`, status, createdAt, worksheets }) + const antwort = [ + ticket('100231', 'awaiting', '2026-10-13T10:00:00.000Z', [ + { serviceType: 'REMOTE', date: '14.10.2026', totalTime: 50, employeeName: 'Lena Vogel', details: 'Fotos erhalten und gesichtet.\nWeitere Zeile.', supporters: '' }, + { serviceType: 'GIT', date: '2026-10-12', totalTime: 0, isComment: true, details: '- [abcdef1](https://git.example/x/commit/abcdef1): feat: Menü angepasst (Kenso)\n- [abcdef2](https://git.example/x/commit/abcdef2): Bilder getauscht (Kenso)' }, + ]), + ticket('100207', 'closed', '2026-10-02T10:00:00.000Z', [{ serviceType: 'ON SITE', date: '04.10.2026', totalTime: 60, employeeName: 'Kenso', details: '' }]), + ].map((t) => ({ ...customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) })) + const el = baueAktivitaet(antwort, JETZT) + const [wartet, fertig] = fx.alle(el, '[data-auftrag]') + assert.deepEqual([wartet.getAttribute('data-auftrag'), fertig.getAttribute('data-auftrag')], ['100231', '100207']) + assert.equal(fx.finde(wartet, '.kn-pille').textContent, 'Wartet auf deine Rückmeldung'); assert.ok(fx.finde(wartet, '.kn-az-info')) + assert.deepEqual(fx.alle(wartet, '.kn-az-zeilen li').map((z) => z.textContent), ['14.10.Fotos erhalten und gesichtet.', '12.10.Menü angepasst und 1 weitere Änderung']) + assert.equal(fx.finde(fertig, '.kn-pille').textContent, 'Erledigt'); assert.deepEqual(fx.alle(fertig, '.kn-az-zeilen li').map((z) => z.textContent), ['04.10.Vor Ort bei dir']) +})) + +// Regression (Browserlauf 17.10.): Die allgemeine Regel `.kn-einklappbar:not(.is-open) [data-extra]` trifft jeden Nachfahren. Läge sie an den verschachtelten Wrappern +// (Aufträge, darin die Zeitleisten), verbärge ein zugeklappter äußerer Wrapper auch die Zeilen einer aufgeklappten Zeitleiste. Darum tragen beide Wrapper eigene Klassen +// und eigene Regeln nur für direkte Kinder. +test('Einklappen: die Wrapper verschachteln sich nicht über .kn-einklappbar, die Regeln gelten nur für direkte Kinder', () => mitDom((fx) => { + const viele = Array.from({ length: 6 }, (_, i) => auftrag(400 + 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) + assert.deepEqual(fx.alle(el, '[data-einklappbar]').map((w) => w.classList.contains('kn-einklappbar')), new Array(7).fill(false)) + assert.equal(el.classList.contains('kn-az-liste'), true); assert.equal(fx.finde(el, '[data-einklappbar] [data-einklappbar]').classList.contains('kn-az-zeitleiste'), true) + const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8') + assert.match(css, /\.kn-az-liste:not\(\.is-open\) > \.kn-az-auftraege > \[data-extra\] \{ display: none; \}/) + assert.match(css, /\.kn-az-zeitleiste:not\(\.is-open\) > \.kn-az-zeilen > \[data-extra\] \{ display: none; \}/) +})) From 1703dabaaff45951314a6889b3e08376635db660 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:31:46 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Kundenansicht=20neu=20(Auftr=C3=A4ge):=20R?= =?UTF-8?q?=C3=BCckfrage-Link=20als=20Block,=20b=C3=BCndige=20Datumsspalte?= =?UTF-8?q?=20bei=20Eintr=C3=A4gen=20aus=20dem=20Vorjahr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browserlauf: display:flex am Link zerlegte den Satz in zwei Spalten; der Link ist jetzt ein Block, die 44 px Tippfläche kommen aus dem Innenabstand. - Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte des Auftrags die Breite dafür (Klasse kn-az-mit-jahr), die Texte bleiben bündig. Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansichten.css | 3 ++- public/neu/auftrags-zeitleiste.js | 3 ++- test/knAuftragsZeitleisteDom.test.js | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 9dc8d3b..39b3b78 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -131,6 +131,7 @@ .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-mit-jahr .kn-az-datum { min-width: 4.4em; } .kn-az-text { min-width: 0; overflow-wrap: anywhere; } .kn-az-zeitleiste:not(.is-open) > .kn-az-zeilen > [data-extra] { display: none; } .kn-az-mehr { margin: 4px 0 0 8px; } @@ -381,7 +382,7 @@ /* 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 { margin-top: 0; } - .kn-az-info a { display: flex; align-items: center; min-height: 44px; } + .kn-az-info a { display: block; padding: 12px 0; } .kn-az-info + .kn-az-balken { margin-top: 0; } .kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .kn-rechnung-offen .kn-trenner { display: none; } diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js index 2f2357c..aa9f350 100644 --- a/public/neu/auftrags-zeitleiste.js +++ b/public/neu/auftrags-zeitleiste.js @@ -148,7 +148,8 @@ function baueZeitleiste(zeilen) { 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))), + // 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', { 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))), rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen' }) : null) } diff --git a/test/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js index 46526ac..2afff6f 100644 --- a/test/knAuftragsZeitleisteDom.test.js +++ b/test/knAuftragsZeitleisteDom.test.js @@ -167,3 +167,17 @@ test('Einklappen: die Wrapper verschachteln sich nicht über .kn-einklappbar, di assert.match(css, /\.kn-az-liste:not\(\.is-open\) > \.kn-az-auftraege > \[data-extra\] \{ display: none; \}/) assert.match(css, /\.kn-az-zeitleiste:not\(\.is-open\) > \.kn-az-zeilen > \[data-extra\] \{ display: none; \}/) })) + +// Browserlauf 17.10.: Ein display:flex am Link zerlegte den Satz in zwei Spalten (Text | „Im Chat antworten“). Der Link ist ein Block; die Tippfläche kommt aus dem Innenabstand. +test('Rückfrage-Link: am Handy ein Block (kein flex) mit Innenabstand für 44 px Tippfläche', () => { + const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8') + assert.match(css, /\n \.kn-az-info a \{ display: block; padding: 12px 0; \}/) + assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*display: *(inline-)?flex/) +}) + +test('Zeitleiste: trägt ein Eintrag das Jahr (Vorjahr), richten sich alle Datumsspalten des Auftrags danach', () => mitDom((fx) => { + const mitJahr = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu'), eintrag('2025-12-24', 'alt')])], JETZT) + assert.deepEqual(fx.alle(mitJahr, 'time').map((t) => t.textContent), ['06.10.', '24.12.25']); assert.ok(fx.finde(mitJahr, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr')) + const ohne = baueAktivitaet([auftrag(2, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu')])], JETZT) + assert.ok(!fx.finde(ohne, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr')) +}))