Kundenansicht neu (Aufträge): "Was wir zuletzt erledigt haben" als Zeitleiste auf der Website-Seite
Je Auftrag eine Überschrift (Titel fett, Status-Pille), ein schmaler Balken mit vier Abschnitten (Stufennamen als Text für Screenreader), bei Rückfrage die Zeile "Wir brauchen eine Info von dir · Im Chat antworten" und eine Zeitleiste mit Haarlinie (Datum TT.MM. und Kurztext, drei sichtbar, "mehr anzeigen"). Fünf Aufträge offen, der Rest hinter "Ältere Aufträge anzeigen". Die Auftragsnummer steht nicht mehr im Text (ruhiger), bleibt als data-auftrag. Die alte baueAktivitaet und ihre toten CSS-Regeln entfallen; die Projektdetails (baueAuftrag, baueArbeiten) bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f231f4b887
commit
2064bddba6
7 files changed
+241
-37
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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', '<img src=x onerror=alert(1)>'), 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 }] },
|
||||
|
||||
@@ -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 = '<img src=x onerror=alert(1)>'
|
||||
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/)
|
||||
}))
|
||||
Reference in new issue
Block a user