Kundenansicht neu P2: Reiter Rechnungen mit Aufträgen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c8d29657d
commit
cf391eb117
2 files changed
+102
No files matched your search
@@ -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 */ }
|
||||
}
|
||||
@@ -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 <i>Roth</i>' }),
|
||||
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/)
|
||||
}))
|
||||
Reference in new issue
Block a user