From 8a23652e071f92a50c05b282285d1ed6d396817b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:02:59 +0000 Subject: [PATCH] Kundenansicht neu P2: Stundenkonto mit Vorschuss-Anzeige Co-Authored-By: Claude Sonnet 5.5 --- public/neu/ansicht-stunden.js | 70 +++++++++++++++++++++++++++++++++++ public/neu/vertraege-abo.js | 12 ++++-- test/knAnsichtStunden.test.js | 35 ++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 public/neu/ansicht-stunden.js create mode 100644 test/knAnsichtStunden.test.js diff --git a/public/neu/ansicht-stunden.js b/public/neu/ansicht-stunden.js new file mode 100644 index 0000000..e347631 --- /dev/null +++ b/public/neu/ansicht-stunden.js @@ -0,0 +1,70 @@ +/* Kundenansicht neu (P2): Reiter Stunden in Verträge. Stundenkonto des Wartungs-Abos mit Verlauf; ein Vorschuss für die Einrichtung + (ab P6 in ledger.vorschussMinutes) wird nie als Mehraufwand gezeigt. */ + +import { h, leeren } from './dom.js' +import { K, fmtStd, fmtDatum, ladeAbo, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' +import { aboWarnungen } from './vertraege-abo.js' + +const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs +const stat = (wert, beschriftung) => h('div', { class: 'kn-stat' }, h('strong', { class: 'kn-num' }, wert), h('span', null, beschriftung)) + +/** Zeile zum Vorschuss für die Einrichtung; bei 0 oder weniger leer. gekuendigt: der Rest verfällt. */ +export function vorschussZeile(minuten, gekuendigt) { + if (!(Number(minuten) > 0)) return '' + return `Vorschuss für Einrichtung: ${fmtStd(minuten)}, ${gekuendigt ? 'verfällt, wird nicht berechnet' : 'wird mit den nächsten Monaten verrechnet'}` +} + +function verlaufZeile(u) { + const vorschuss = Number(u.vorschussMinutes) || 0 + const mehr = Number(u.overageMinutes) || 0 + const pille = vorschuss > 0 + ? h('span', { class: K.pille.violett }, `Vorschuss ${fmtStd(vorschuss)}`) + : mehr > 0 ? h('span', { class: K.pille.orange, title: 'Über das Kontingent hinaus' }, `+${fmtStd(mehr)} extra`) : null + return h('tr', null, + h('td', { 'data-label': 'Datum' }, fmtDatum(u.date)), + h('td', { 'data-label': 'Vorgang' }, u.topic || u.details || 'Arbeitszeit', u.woid ? h('span', { class: 'kn-zeile-sub' }, ` #${u.woid}`) : null), + h('td', { 'data-label': 'Zeit', class: 'kn-num' }, `−${fmtStd(u.minutes)}`, u.supportMinutes ? h('span', { class: 'kn-zeile-sub' }, ` inkl. ${fmtStd(u.supportMinutes)} Unterstützung`) : null, pille ? ' ' : null, pille)) +} + +function gutschriftZeile(c) { + return h('tr', null, + h('td', { 'data-label': 'Datum' }, fmtDatum(c.grantedAt)), + h('td', { 'data-label': 'Vorgang' }, 'Monatskontingent'), + h('td', { 'data-label': 'Zeit', class: 'kn-num' }, `+${fmtStd(c.minutes)}`, h('span', { class: 'kn-zeile-sub' }, ` ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDatum(c.expiresAt)}`}`))) +} + +/** Reiter-Inhalt Stunden. daten (GET /api/abo) ist optional; ohne sie lädt die Funktion selbst. */ +export async function zeigeStunden(wurzel, { route, me, nav, daten } = {}) { + const nr = (laeufe.get(wurzel) ?? 0) + 1 + laeufe.set(wurzel, nr) + const aktuell = () => laeufe.get(wurzel) === nr + leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) + + const abo = daten ?? await ladeAbo() + if (!aktuell()) return + if (!abo) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return } + const sub = abo.subscription + const l = abo.ledger + if (!sub?.active || sub.productKey !== 'wartung' || !l) { + leeren(wurzel).append(h('p', { class: K.leer }, 'Ein Stundenkonto gibt es im Wartungs-Abo.')) + return + } + const gekuendigt = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt) + const vorschuss = vorschussZeile(l.vorschussMinutes, gekuendigt) + const zeilen = [...(l.usage || []).map(verlaufZeile), ...(l.credits || []).slice().reverse().map(gutschriftZeile)] + + leeren(wurzel).append( + ...aboWarnungen(sub), + h('div', { class: 'kn-statistik' }, + stat(fmtStd(l.balanceMinutes), 'verfügbare Zeit'), + stat(fmtStd(l.usedMinutes), 'bisher genutzt'), + l.nextExpiry ? stat(fmtStd(l.nextExpiry.minutes), `verfallen am ${fmtDatum(l.nextExpiry.at)}`) : null), + vorschuss ? h('p', { class: 'kn-hinweis kn-vorschuss' }, vorschuss) : null, + h('p', { class: 'kn-hinweis' }, '1 Stunde pro bezahltem Monat. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.'), + h('p', { class: 'kn-links' }, h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Leistungen & Bedingungen')), + h('section', { class: 'kn-sektion' }, + h('h2', { class: K.abschnitt }, 'Verlauf'), + zeilen.length + ? h('div', { class: 'kn-tabelle' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Datum'), h('th', null, 'Vorgang'), h('th', null, 'Zeit'))), h('tbody', null, zeilen))) + : h('p', { class: 'kn-hinweis' }, 'Noch keine Buchungen.'))) +} diff --git a/public/neu/vertraege-abo.js b/public/neu/vertraege-abo.js index dc27517..52fd3e2 100644 --- a/public/neu/vertraege-abo.js +++ b/public/neu/vertraege-abo.js @@ -11,6 +11,14 @@ const TEXT_ZAHLUNG = 'Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisier const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus nicht möglich.' const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.' +/** Die zwei Warnungen über dem Vertrag (auch im Stundenkonto): fehlgeschlagene Abbuchung, späterer Beginn nach der Widerrufsfrist. */ +export function aboWarnungen(sub) { + const aus = [] + if (sub?.active && sub.paymentProblem) aus.push(h('p', { class: `${K.meldung} kn-meldung-warn` }, TEXT_ZAHLUNG)) + if (sub?.active && sub.serviceStartsAt) aus.push(h('p', { class: K.meldung }, `Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDatum(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.`)) + return aus +} + const gekuendigt = (sub) => Boolean(sub?.cancelAtPeriodEnd || sub?.cancelAt) const produktName = (daten, key) => { const p = (daten?.products || []).find((x) => x.key === key) @@ -153,9 +161,7 @@ export async function zeigeAbo(box, { daten, me } = {}) { const zustand = aboZustand(daten) const gutschrift = sub ? gutschriftLink() : null - const teile = [] - if (sub?.active && sub.paymentProblem) teile.push(h('p', { class: `${K.meldung} kn-meldung-warn` }, TEXT_ZAHLUNG)) - if (sub?.active && sub.serviceStartsAt) teile.push(h('p', { class: K.meldung }, `Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDatum(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.`)) + const teile = aboWarnungen(sub) if (sub?.active) { teile.push(vertragsBlock(daten, sub, viewAs), wechselKarte(daten, sub)) } else { diff --git a/test/knAnsichtStunden.test.js b/test/knAnsichtStunden.test.js new file mode 100644 index 0000000..1b2be76 --- /dev/null +++ b/test/knAnsichtStunden.test.js @@ -0,0 +1,35 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigeStunden, vorschussZeile } from '../public/neu/ansicht-stunden.js' +import { mit, me, abo, aboAntwort, ledger, neueWurzel } from './support/knDaten.js' + +const L = ledger({ balanceMinutes: 90, usedMinutes: 120, vorschussMinutes: 150, nextExpiry: { minutes: 60, at: '2027-03-01T00:00:00.000Z' }, + credits: [{ grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2027-10-01T00:00:00.000Z', minutes: 60, remaining: 60, expired: false }], + usage: [{ date: '2026-10-10T12:00:00.000Z', topic: 'Einrichtung Terminplaner', woid: '100150', minutes: 150, overageMinutes: 0, vorschussMinutes: 150, einrichtung: true }, + { date: '2026-10-05T12:00:00.000Z', topic: 'Textänderung neu', woid: '100140', minutes: 30, overageMinutes: 30, vorschussMinutes: 0, einrichtung: false }] }) +test('vorschussZeile', () => { + assert.equal(vorschussZeile(150, false), 'Vorschuss für Einrichtung: 2,5 Std., wird mit den nächsten Monaten verrechnet') + assert.equal(vorschussZeile(150, true), 'Vorschuss für Einrichtung: 2,5 Std., verfällt, wird nicht berechnet'); assert.equal(vorschussZeile(0, false), '') +}) +test('Stunden: Statistik, Vorschuss nie als Mehraufwand, Mehraufwand bleibt Mehraufwand', mit({}, async (fx) => { + const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', reiter: 'stunden', query: '' }, me: me(), nav: 'tab', daten: aboAntwort({ subscription: abo(), ledger: L }) }) + const t = fx.text(w) + for (const s of ['1,5 Std.', '2 Std.', 'verfallen am 01.03.2027', 'Vorschuss für Einrichtung: 2,5 Std., wird mit den nächsten Monaten verrechnet', 'Monatskontingent', '+1 Std.', 'nutzbar bis 01.10.2027', '#100150']) assert.ok(t.includes(s), s) + const [vorschuss, mehr] = fx.alle(w, 'tbody tr'); assert.match(vorschuss.textContent, /Vorschuss 2,5 Std\./); assert.doesNotMatch(vorschuss.textContent, /extra/); assert.match(mehr.textContent, /\+0,5 Std\. extra/) + assert.equal(fx.finde(w, 'b'), null) +})) +test('Stunden: gekündigtes Abo verfällt den Vorschuss, Hosting und fehlendes Konto sagen es', mit({}, async (fx) => { + const zeige = async (daten) => { const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab', daten }); await fx.ruhe(); return fx.text(w) } + assert.match(await zeige(aboAntwort({ subscription: abo({ cancelAtPeriodEnd: true }), ledger: L })), /verfällt, wird nicht berechnet/) + assert.match(await zeige(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), /Ein Stundenkonto gibt es im Wartungs-Abo\./) + assert.match(await zeige(aboAntwort({ subscription: abo(), ledger: null })), /Ein Stundenkonto gibt es im Wartungs-Abo\./) +})) +test('Stunden lädt selbst, wenn keine Daten übergeben werden, und überlebt einen Ausfall', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { + const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe(); assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./) +})) +test('Stunden lädt ohne Daten das Abo; Leerer Verlauf, Satz und Link zu den Bedingungen; Warnungen', mit({ 'GET /api/abo': aboAntwort({ subscription: abo({ paymentProblem: true, termsUrl: '/vertragsunterlagen/v4/abo-bedingungen.html' }), ledger: ledger({ usage: [], credits: [] }) }) }, async (fx) => { + const w = neueWurzel(fx); await zeigeStunden(w, { route: { bereich: 'vertraege', query: '' }, me: me(), nav: 'tab' }); await fx.ruhe() + assert.match(fx.text(w), /Noch keine Buchungen\./); assert.match(fx.text(w), /1 Stunde pro bezahltem Monat\. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst\./) + assert.match(fx.text(w), /Bitte aktualisiere deine Zahlungsart/) + const a = fx.finde(w, 'a[href="/vertragsunterlagen/v4/abo-bedingungen.html"]'); assert.equal(a.textContent, 'Leistungen & Bedingungen') +}))