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:
1 parent
cad9ba7e40
commit
b53f9f67ed
3 files changed
+272
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
Reference in new issue
Block a user