From cf391eb1176b66e6548874a88f4b5958721af711 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:00:28 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P2:=20Reiter=20Rechnungen?= =?UTF-8?q?=20mit=20Auftr=C3=A4gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/vertraege-rechnungen.js | 65 ++++++++++++++++++++++++++++++ test/knAnsichtRechnungen.test.js | 37 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 public/neu/vertraege-rechnungen.js create mode 100644 test/knAnsichtRechnungen.test.js diff --git a/public/neu/vertraege-rechnungen.js b/public/neu/vertraege-rechnungen.js new file mode 100644 index 0000000..58fadf2 --- /dev/null +++ b/public/neu/vertraege-rechnungen.js @@ -0,0 +1,65 @@ +/* Kundenansicht neu (P2): Reiter Rechnungen in Verträge. Oben der Abschnitt "Aufträge" (Alt-Modul auftrag-portal.js, blendet sich selbst ein, + wenn Aufträge aktiv und vorhanden sind), darunter die Rechnungen als Tabelle. Fehler der Aufträge stören die Rechnungen nie. */ + +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { K, legacyRequest, safeUrl, fmtEuro, fmtDatum } from './ansicht-hilfen.js' +import { initAuftraege } from '../auftrag-portal.js' + +const TEXT_LEER = 'Es liegen noch keine Rechnungen vor.' +const TEXT_LUECKE = 'Deine Rechnungen konnten gerade nicht vollständig geladen werden. Bitte versuche es später noch einmal.' +const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs + +const pille = (r) => (r.overdue ? K.pille.rot : r.paid ? K.pille.gruen : r.cancelled ? K.pille.grau : K.pille.orange) + +function rechnungsZeile(r) { + const offen = Number(r.balance) > 0 + const url = safeUrl(r.portalUrl) + const links = [ + url ? h('a', { class: 'kn-zeilenlink', href: url, target: '_blank', rel: 'noopener noreferrer' }, offen ? 'Zahlen' : 'Ansehen') : null, + r.hasPdf !== false ? h('a', { class: 'kn-zeilenlink', href: `/api/invoices/${encodeURIComponent(r.id)}/pdf` }, 'PDF') : null, + ].filter(Boolean) + return h('tr', { class: offen ? 'is-offen' : 'is-bezahlt' }, + h('td', { 'data-label': 'Nr.', class: 'kn-rechnung-nr' }, r.number, r.projekt ? h('span', { class: 'kn-rechnung-projekt' }, r.projekt) : null), + h('td', { 'data-label': 'Datum', class: 'kn-num' }, fmtDatum(r.date)), + h('td', { 'data-label': 'Betrag', class: 'kn-num' }, fmtEuro(r.amount)), + h('td', { 'data-label': 'Offen', class: 'kn-num' }, fmtEuro(r.balance)), + h('td', { 'data-label': 'Status' }, h('span', { class: pille(r) }, r.status)), + h('td', { class: 'kn-rechnung-aktionen' }, links)) +} + +function tabelle(rechnungen) { + return h('div', { class: 'kn-tabelle kn-rechnungen' }, h('table', null, + h('thead', null, h('tr', null, ['Nr.', 'Datum', 'Betrag', 'Offen', 'Status'].map((t) => h('th', null, t)), h('th', { 'aria-label': 'Aktionen' }))), + h('tbody', null, rechnungen.map(rechnungsZeile)))) +} + +/** Reiter-Inhalt. antwort ist optional die schon geladene Antwort von GET /api/invoices; ohne sie lädt die Funktion selbst. */ +export async function zeigeRechnungen(box, { me, antwort } = {}) { + const nr = (laeufe.get(box) ?? 0) + 1 + laeufe.set(box, nr) + const aktuell = () => laeufe.get(box) === nr + + const auftraegeAbschnitt = h('section', { class: 'ab-portal hidden', id: 'kn-auftraege' }, h('h2', { class: K.abschnitt }, 'Aufträge'), h('div', { class: 'kn-auftraege-rumpf' })) + const rechnungenTitel = h('h2', { class: `${K.abschnitt} hidden` }, 'Rechnungen') + const liste = h('div', { class: 'kn-rechnungen-liste' }, h('p', { class: K.leer }, 'Wird geladen …')) + leeren(box).append(auftraegeAbschnitt, rechnungenTitel, liste) + + let daten = antwort + if (!daten) { + try { daten = await api('/api/invoices') } catch (f) { + if (!aktuell()) return + leeren(liste).append(h('p', { class: K.fehler }, `Rechnungen konnten nicht geladen werden: ${f.message}`)) + } + } + if (!aktuell()) return + if (daten) { + const rechnungen = Array.isArray(daten.invoices) ? daten.invoices : [] + const luecke = daten.incomplete ? h('p', { class: K.meldung }, TEXT_LUECKE) : null + leeren(liste).append(...(rechnungen.length ? [tabelle(rechnungen), luecke] : [luecke ?? h('p', { class: 'kn-hinweis' }, TEXT_LEER)]).filter(Boolean)) + } + // Aufträge: ein Fehler lässt den Abschnitt weg und lässt die Rechnungen stehen + try { + await initAuftraege({ section: auftraegeAbschnitt, body: auftraegeAbschnitt.querySelector('.kn-auftraege-rumpf'), invoicesTitle: rechnungenTitel, request: legacyRequest(api), viewAs: me?.viewAs === true }) + } catch { /* ohne Aufträge bleiben die Rechnungen */ } +} diff --git a/test/knAnsichtRechnungen.test.js b/test/knAnsichtRechnungen.test.js new file mode 100644 index 0000000..e3849bd --- /dev/null +++ b/test/knAnsichtRechnungen.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigeRechnungen } from '../public/neu/vertraege-rechnungen.js' +import { mit, me, rechnung, neueWurzel } from './support/knDaten.js' +import { eintrag } from './auftraegeKundenportalDaten.js' + +test('Rechnungen: Zeilen, Links nur mit sicherer Adresse, Projekt bleibt Text', mit({ 'GET /api/invoices': { invoices: [ + rechnung({ id: 'in_1', number: 'RE-2026-0042', amount: 50, balance: 50, status: 'Offen', portalUrl: 'https://invoice.stripe.com/i/x', hasPdf: true, projekt: 'Bäckerei Roth' }), + rechnung({ id: 'in_0', number: 'RE-2026-0030', amount: 20, balance: 0, paid: true, status: 'Bezahlt', portalUrl: 'javascript:alert(1)', hasPdf: true })], incomplete: false }, 'GET /api/auftraege': { aktiv: false } }, async (fx) => { + const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me() }); await fx.ruhe() + const zeilen = fx.alle(box, 'tbody tr'); assert.equal(zeilen.length, 2); assert.match(zeilen[0].textContent, /50,00 €/); assert.equal(fx.finde(box, 'i'), null) + assert.deepEqual(fx.alle(zeilen[0], 'a').map((a) => [a.textContent, a.getAttribute('href')]), [['Zahlen', 'https://invoice.stripe.com/i/x'], ['PDF', '/api/invoices/in_1/pdf']]) + assert.deepEqual(fx.alle(zeilen[1], 'a').map((a) => a.textContent), ['PDF']) +})) +let antwort +test('Rechnungen: Ausfall, leere Liste und unvollständige Antwort', mit({ 'GET /api/invoices': () => antwort, 'GET /api/auftraege': { aktiv: false } }, async (fx) => { + const zeige = async (a) => { antwort = a; const b = neueWurzel(fx); await zeigeRechnungen(b, { me: me() }); await fx.ruhe(); return fx.text(b) } + assert.match(await zeige({ status: 500, body: { error: 'Server kaputt' } }), /Rechnungen konnten nicht geladen werden: Server kaputt/) + assert.match(await zeige({ body: { invoices: [] } }), /Es liegen noch keine Rechnungen vor\./) + assert.match(await zeige({ body: { invoices: [rechnung({ balance: 0 })], incomplete: true } }), /Deine Rechnungen konnten gerade nicht vollständig geladen werden\. Bitte versuche es später noch einmal\./) +})) +test('Aufträge bestätigen: der POST-Body ist ein Objekt, kein JSON-String (Review Focus 1)', mit({ 'GET /api/invoices': { invoices: [] }, 'GET /api/auftraege': { aktiv: true, auftraege: [eintrag({ id: 'ab1' })] }, 'POST /api/auftraege/ab1/bestaetigen': () => ({ body: { auftrag: { ...eintrag({ id: 'ab1' }), status: 'bestaetigt' } } }) }, async (fx) => { + const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me() }); await fx.ruhe() + await fx.klick(fx.finde(box, '[data-feld="bestaetigung"]')); await fx.klick(fx.finde(box, '[data-aktion="bestaetigen"]')) + const post = fx.fetchLog.find((a) => a.method === 'POST'); assert.equal(post.url, '/api/auftraege/ab1/bestaetigen'); assert.equal(typeof post.body, 'object') + assert.equal(fx.finde(box, '#kn-auftraege').classList.contains('hidden'), false) +})) +test('Aufträge im Ansichtsmodus: Häkchen gesperrt, Hinweis sichtbar, kein POST', mit({ 'GET /api/invoices': { invoices: [] }, 'GET /api/auftraege': { aktiv: true, auftraege: [eintrag({ id: 'ab1', kannBestaetigen: false })] } }, async (fx) => { + const box = neueWurzel(fx); await zeigeRechnungen(box, { me: me({ viewAs: true }) }); await fx.ruhe() + assert.ok(fx.finde(box, '[data-gesperrt]')); assert.equal(fx.finde(box, '[data-feld="bestaetigung"]').hasAttribute('disabled'), true); assert.deepEqual(fx.fetchLog.filter((a) => a.method === 'POST'), []) +})) +test('Rechnungen: schon geladene Antwort wird benutzt (kein zweiter Abruf), Aufträge-Ausfall stört nicht', mit({ 'GET /api/auftraege': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { + const box = neueWurzel(fx) + await zeigeRechnungen(box, { me: me(), antwort: { invoices: [rechnung({ id: 'in_9', number: 'RE-9', balance: 10, overdue: true, status: 'Überfällig', portalUrl: 'https://invoice.stripe.com/i/y' })] } }); await fx.ruhe() + assert.ok(!fx.fetchLog.some((a) => a.url === '/api/invoices')); assert.match(fx.text(box), /RE-9/); assert.equal(fx.finde(box, '#kn-auftraege').classList.contains('hidden'), true) + const z = fx.finde(box, 'tbody tr'); assert.equal(fx.finde(z, 'td[data-label="Nr."]') !== null, true); assert.match(fx.finde(z, '.kn-pille').className, /kn-pille-rot/) +}))