Kundenportal Aufträge: Abschnitt „Aufträge“ im Portal (Karten, Bestätigen, Ansichtsmodus gesperrt)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:15:29 +00:00
1 parent cad9ba7e40
commit b53f9f67ed
3 files changed
+272

No files matched your search

+84
View File
@@ -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 }
}
+29
View File
@@ -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) {
+159
View File
@@ -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/<id>/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 = '<script>alert(1)</script> & "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)')
})