diff --git a/public/auftrag-portal.js b/public/auftrag-portal.js new file mode 100644 index 0000000..6f8e711 --- /dev/null +++ b/public/auftrag-portal.js @@ -0,0 +1,84 @@ +/* Abschnitt „Aufträge“ im Reiter Rechnungen des Kundenportals (geladen von app.js, loadCustomerOrders; request ist api aus app.js). + Kundentext kommt nur über textContent in die Seite. Alle Texte stehen in auftrag-ansicht.js; das Portal spricht in der Du-Form. */ +import { + ANREDE_DU, agbPfad, darfBestaetigen, kartenMeta, sortiereAuftraege, statusAnzeige, texte, +} from './auftrag-ansicht.js' +import { + h, renderBestaetigtBlock, renderBestaetigung, renderDokumente, renderZusammenfassung, +} from './auftrag-dom.js' + +export async function initAuftraege({ section, body, invoicesTitle, request, viewAs = false } = {}) { + if (!section || !body || typeof request !== 'function') return { sichtbar: false } + const t = texte(ANREDE_DU) + let eintraege = [] + + /** Bestätigen darf, wer der Server dafür freigibt; in der Kundenansicht zeigt die Oberfläche die offene AB mit gesperrtem Formular. */ + const wirksam = (e) => darfBestaetigen(e) && (viewAs || e.kannBestaetigen === true) + + async function ladeListe() { + const daten = await request('/api/auftraege') + if (daten?.aktiv !== true || !Array.isArray(daten.auftraege)) return [] + return daten.auftraege.filter((e) => e && typeof e.id === 'string' && e.id !== '') + } + + function karte(auftrag, offeneId) { + const kann = wirksam(auftrag) + const status = statusAnzeige({ ...auftrag, kannBestaetigen: kann }) + const pfad = `/api/auftraege/${encodeURIComponent(auftrag.id)}` + + async function senden(koerper) { + try { + const antwort = await request(`${pfad}/bestaetigen`, { method: 'POST', body: JSON.stringify(koerper) }) + return antwort?.auftrag ? { ok: true, auftrag: antwort.auftrag } : { ok: false, status: 0, body: {} } + } catch (err) { + return { ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } } + } + } + function nachErfolg(neu) { + eintraege = eintraege.map((alt) => (alt.id === auftrag.id ? { ...alt, ...neu, id: alt.id, kannBestaetigen: false } : alt)) + zeichne(auftrag.id) + body.querySelector(`[data-auftrag="${CSS.escape(auftrag.id)}"] [data-bestaetigt]`)?.focus() + } + async function nachEnde() { + try { + eintraege = await ladeListe() + zeichne('') + } catch { + /* die Anzeige bleibt stehen */ + } + } + + return h('article', { class: 'ab-karte', 'data-auftrag': auftrag.id, 'data-status': auftrag.status || '' }, + h('div', { class: 'ab-karte-kopf' }, + h('div', null, + h('p', { class: 'ab-karte-nummer' }, h('strong', null, auftrag.nummer || '')), + auftrag.titel ? h('p', { class: 'ab-karte-titel' }, auftrag.titel) : null, + h('p', { class: 'ab-karte-meta' }, kartenMeta(auftrag))), + h('span', { class: `ab-pille ab-pille-${status.ton}` }, status.text)), + h('div', { class: 'ab-karte-aktionen' }, + h('a', { class: 'ab-link', 'data-link': 'pdf', href: `${pfad}/pdf`, target: '_blank', rel: 'noopener noreferrer' }, t.kartePdf)), + h('details', { class: 'ab-details', open: kann || auftrag.id === offeneId }, + h('summary', null, kann ? t.karteDetailsBestaetigen : t.karteDetails), + renderZusammenfassung(auftrag, { ebene: 4 }), + renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) }), + renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU }), + kann + ? renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? t.ansichtsmodus : '', anrede: ANREDE_DU }) + : null)) + } + + function zeichne(offeneId) { + body.replaceChildren(...sortiereAuftraege(eintraege.map((e) => ({ ...e, kannBestaetigen: wirksam(e) }))).map((e) => karte(eintraege.find((x) => x.id === e.id), offeneId))) + } + + try { + eintraege = await ladeListe() + } catch { + return { sichtbar: false } + } + if (!eintraege.length) return { sichtbar: false } + zeichne('') + section.classList.remove('hidden') + invoicesTitle?.classList.remove('hidden') + return { sichtbar: true } +} diff --git a/public/auftrag.css b/public/auftrag.css index dee3b6e..8463188 100644 --- a/public/auftrag.css +++ b/public/auftrag.css @@ -122,6 +122,35 @@ body.ab-seite { .ab-seite .ab-knopf, .ab-portal .ab-knopf { width: 100%; } } +/* ------------------------------------------------------------------ Portal (Reiter Rechnungen, dunkel) --- */ +.ab-portal { + --ab-karte: var(--kb-surface); --ab-text: #ffffff; --ab-weich: var(--kb-muted); --ab-linie: var(--kb-border); --ab-box: rgba(255, 255, 255, 0.04); + --ab-knopf: var(--kb-purple); --ab-knopf-text: #ffffff; --ab-fokus: #d4b4ff; + --ab-warn-bg: rgba(157, 61, 254, 0.16); --ab-warn-text: #d8b4fe; --ab-ok-bg: rgba(74, 222, 128, 0.1); --ab-ok-text: #86efac; + --ab-rot-bg: rgba(248, 113, 113, 0.12); --ab-rot-text: #fca5a5; --ab-muted-bg: rgba(255, 255, 255, 0.06); --ab-muted-text: var(--kb-muted); + --ab-fehler: #fca5a5; + margin: 0 0 28px; color: var(--ab-text); +} +.ab-portal h2 { font-size: 20px; margin: 0 0 6px; } +.ab-portal .ab-karte { + margin: 12px 0 0; padding: 16px 18px; border-radius: 16px; background: var(--ab-karte); border: 1px solid var(--ab-linie); overflow-wrap: anywhere; +} +.ab-portal .ab-karte-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.ab-portal .ab-karte-kopf .ab-pille { margin: 0; flex: none; } +.ab-portal .ab-karte-kopf p { margin: 0; } +.ab-portal .ab-karte-titel { margin-top: 2px; } +.ab-portal .ab-karte-meta { margin-top: 2px; font-size: 13px; color: var(--ab-weich); } +.ab-portal .ab-karte-aktionen { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } +.ab-portal .ab-karte-aktionen a { + display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); + color: var(--ab-text); text-decoration: none; font-weight: 600; font-size: 14px; +} +.ab-portal .ab-details > summary { cursor: pointer; display: flex; align-items: center; min-height: 44px; color: #d4b4ff; font-weight: 700; } +.ab-portal .ab-details[open] > summary { margin-bottom: 4px; } +.ab-portal .ab-knopf { border-radius: 999px; } +.ab-rechnungen-titel { font-size: 20px; margin: 0 0 10px; } +.inv-project { display: block; margin-top: 2px; font-size: 12px; font-weight: 400; color: var(--kb-muted, #6b6b70); overflow-wrap: anywhere; } + /* ------------------------------------------------------------------ Installierte App --- */ .ab-app-back { display: none; } @media (display-mode: standalone) { diff --git a/test/auftraegeKundenportalPortal.test.js b/test/auftraegeKundenportalPortal.test.js new file mode 100644 index 0000000..c480c29 --- /dev/null +++ b/test/auftraegeKundenportalPortal.test.js @@ -0,0 +1,159 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h } from '../public/auftrag-dom.js' +import { initAuftraege } from '../public/auftrag-portal.js' +import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js' + +const NB = '\u{a0}' +const mit = (fn) => async () => { + const fx = installFakeDom() + try { + await fn(fx) + } finally { + fx.restore() + } +} +function aufbau(fx) { + const body = h('div', { id: 'orders-body' }) + const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body) + const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen') + fx.document.body.append(section, invoicesTitle) + return { section, body, invoicesTitle } +} +/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */ +function fakeRequest(antworten) { + const aufrufe = [] + const request = async (pfad, optionen = {}) => { + aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined }) + const antwort = antworten.shift() + if (antwort instanceof Error) throw antwort + return antwort + } + return { request, aufrufe } +} +const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status }) +const sichtbar = (el) => !el.classList.contains('hidden') +const liste = (...auftraege) => ({ aktiv: true, auftraege }) + +const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' }) +const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht +const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false }) +const A_STORNO = eintrag({ + id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, + aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', +}) +const bestaetige = async (fx, wurzel) => { + await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]')) + await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]')) +} + +test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => { + for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([antwort]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false }) + assert.equal(sichtbar(els.section), false) + assert.equal(sichtbar(els.invoicesTitle), false) + assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }]) + els.section.remove() + els.invoicesTitle.remove() + } +})) + +test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => { + const els = aufbau(fx) + const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)]) + assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true }) + assert.equal(sichtbar(els.section), true) + assert.equal(sichtbar(els.invoicesTitle), true) + const karten = fx.alle(els.body, '.ab-karte') + assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0']) + const [offen, ohneRecht, fertig, storno] = karten + assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen') + assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet') + assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular') + assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf') + assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`) + assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand') + assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf') + assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen') + assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen') + assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen') + assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true) + assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false) + assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]')) + assert.equal(fx.finde(fertig, 'form'), null) + assert.equal(fx.finde(storno, 'form'), null) + assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.']) + assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form') + assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.') + assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine']) + assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf') +})) + +test('Bestätigen: POST an /api/auftraege//bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => { + const els = aufbau(fx) + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } }) + const karte = fx.finde(els.body, '.ab-karte') + assert.equal(karte.getAttribute('data-auftrag'), 'ab1') + assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt') + assert.equal(fx.finde(karte, 'form'), null) + assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true) + assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]')) +})) + +test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => { + const els = aufbau(fx) + // wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular + const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))]) + await initAuftraege({ ...els, request, viewAs: true }) + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen') + const form = fx.finde(els.body, 'form') + assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true) + assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true) + assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') + await fx.sende(form, 'submit') + assert.equal(aufrufe.length, 1, 'nur das Laden der Liste') + assert.ok(fx.finde(els.body, '[data-link="pdf"]')) + assert.ok(fx.finde(els.body, '[data-position]')) +})) + +test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => { + const els = aufbau(fx) + const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false } + const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)]) + await initAuftraege({ ...els, request, viewAs: false }) + await bestaetige(fx, els.body) + assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.') + assert.ok(!fx.text(els.body).includes('Fehler 500')) + await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]')) + assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu') + assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert') + assert.equal(fx.finde(els.body, 'form'), null) +})) + +test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => { + const els = aufbau(fx) + const boese = ' & "Test"' + const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))]) + await initAuftraege({ ...els, request, viewAs: false }) + assert.equal(fx.alle(els.body, 'script').length, 0) + assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese) + assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf') +})) + +test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => { + pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] }) + const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8') + for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) { + assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`) + } + assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)') +})