Design P6: reine Helfer fuer Gutschrift-, Angebots- und Speicherzeilen mit Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:36:10 +00:00
1 parent 910fa0ff30
commit b81e53eef5
6 files changed
+146 -2

No files matched your search

+11
View File
@@ -10,6 +10,17 @@ export const CREDIT_STATUS = {
} }
export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status } export const creditStatus = (status) => CREDIT_STATUS[status] || { tone: 'muted', label: status }
/** Knopf der Listenzeile: offene und rueckgefragte Antraege werden geprueft, alle anderen nur angesehen */
export const creditActionLabel = (status) => (['offen', 'rueckfrage'].includes(status) ? 'Prüfen' : 'Ansehen')
/** Zweite Zeile unter dem Status: bei genehmigten Antraegen Betrag und Buchungsstand, sonst leer */
export const creditStatusNote = (r) =>
(r.status === 'genehmigt' ? `${formatCents(r.amountCents)}${r.appliedAt ? '' : ' · nicht gebucht'}` : '')
/** Zeichen und Ton einer Zeile der automatischen Pruefung (unbekannt: "i" in gedaempftem Ton) */
export const checkGlyph = (status) => (status === 'ok' ? '✓' : status === 'warn' ? '!' : 'i')
export const checkTone = (status) => (status === 'ok' || status === 'warn' || status === 'info' ? status : 'muted')
const HISTORY_LABELS = { const HISTORY_LABELS = {
eingereicht: 'Antrag eingereicht', eingereicht: 'Antrag eingereicht',
rueckfrage: 'Rückfrage an den Kunden', rueckfrage: 'Rückfrage an den Kunden',
+19 -1
View File
@@ -1,4 +1,4 @@
import { berlinYmd, fmtBerlin, fmtDate, fmtYmd, formatCents, PRODUCT_LABEL } from './format' import { berlinYmd, fmtBerlin, fmtDate, fmtDateTime, fmtYmd, formatCents, PRODUCT_LABEL } from './format'
// Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem frueheren Admin-Panel des Portals. // Abo-Angebote: Texte, Rabattende und Formularpruefung wortgleich aus dem frueheren Admin-Panel des Portals.
// "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar. // "today" ist der Kalendertag in Deutschland (YYYY-MM-DD) und fuer Tests injizierbar.
@@ -11,6 +11,24 @@ export const OFFER_STATUS = {
} }
export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status } export const offerStatus = (status) => OFFER_STATUS[status] || { tone: 'muted', label: status }
/** Listenfilter: "all" zeigt alles, sonst nur offene und aktive Angebote; ohne Liste leer, die Eingabe bleibt unveraendert */
export const filterOffers = (offers, filter) =>
(offers || []).filter((o) => filter === 'all' || ['offen', 'aktiv'].includes(o.status))
/** Aktionen der Zeile: Beschriftung des E-Mail-Knopfs ('' = kein Knopf) und ob Zurueckziehen moeglich ist */
export const offerRowActions = (o) => ({
sendLabel: o.mailSendable ? (o.notifiedAt ? 'Erneut senden' : 'E-Mail senden') : '',
canWithdraw: ['offen', 'abgelaufen'].includes(o.status),
})
/** Mail-Zeile unter dem Status (Reihenfolge: gesendet, nicht verschickt, ohne Adresse), sonst null */
export function offerMailLine(o) {
if (o.notifiedAt) return { tone: 'muted', text: `E-Mail gesendet ${fmtDateTime(o.notifiedAt)}` }
if (o.mailSendable) return { tone: 'warn', text: 'E-Mail nicht verschickt' }
if (o.status === 'offen' && !o.customerEmail) return { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' }
return null
}
const todayBerlin = () => berlinYmd(new Date()) const todayBerlin = () => berlinYmd(new Date())
export function offerPreviewText(price, months, regular, running) { export function offerPreviewText(price, months, regular, running) {
+7 -1
View File
@@ -1,4 +1,4 @@
import { fmtDate } from './format' import { fmtDate, fmtDateTime } from './format'
// E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem frueheren Admin-Panel des Portals. // E-Mail-Speicher (Zusatzspeicher-Aufgaben in konsoleH): Texte wortgleich aus dem frueheren Admin-Panel des Portals.
@@ -35,5 +35,11 @@ export const orderedKind = (t) => (t.toUnits > t.fromUnits ? 'Bestellung (Plus)'
export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now) export const isOverdue = (t, now = Date.now()) => Boolean(t.dueAt && new Date(t.dueAt).getTime() <= now)
/** Faelligkeitsspalte: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn); sub in gedaempft, wenn subMuted */
export function storageDue(t, now = Date.now()) {
if (t.kind === 'einrichten') return { tone: 'warn', label: 'jetzt einrichten', sub: `bestellt ${fmtDateTime(t.createdAt)}`, subMuted: true }
return { tone: isOverdue(t, now) ? 'warn' : 'muted', label: 'zum Periodenende', sub: fmtDate(t.dueAt), subMuted: false }
}
export const failedText = (f) => export const failedText = (f) =>
`${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}` `${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}`
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Zeilen der Gutschriften (Liste und Detail) als reine Helfer, damit die Seiten keine Inline-Ausdruecke brauchen
const { creditStatus, creditActionLabel, creditStatusNote, checkGlyph, checkTone } = await loadModule('src/lib/creditFormat.js')
const NB = ' '
test('creditStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
assert.deepEqual(creditStatus('offen'), { tone: 'warn', label: 'Zu prüfen' })
assert.deepEqual(creditStatus('rueckfrage'), { tone: 'muted', label: 'Rückfrage beim Kunden' })
assert.deepEqual(creditStatus('genehmigt'), { tone: 'ok', label: 'Genehmigt' })
assert.deepEqual(creditStatus('abgelehnt'), { tone: 'danger', label: 'Abgelehnt' })
assert.deepEqual(creditStatus('neu'), { tone: 'muted', label: 'neu' })
})
test('creditActionLabel: offene und rueckgefragte Antraege werden geprueft, alle anderen angesehen', () => {
assert.equal(creditActionLabel('offen'), 'Prüfen')
assert.equal(creditActionLabel('rueckfrage'), 'Prüfen')
for (const s of ['genehmigt', 'abgelehnt', 'neu', undefined]) assert.equal(creditActionLabel(s), 'Ansehen')
})
test('creditStatusNote: nur genehmigte Antraege tragen Betrag und Buchungsstand', () => {
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250, appliedAt: '2026-10-01T10:00:00Z' }), `12,50${NB}€`)
assert.equal(creditStatusNote({ status: 'genehmigt', amountCents: 1250 }), `12,50${NB}€ · nicht gebucht`)
assert.equal(creditStatusNote({ status: 'offen', amountCents: 1250 }), '')
})
test('checkGlyph und checkTone: Zeichen und Ton der automatischen Pruefung', () => {
assert.equal(checkGlyph('ok'), '✓')
assert.equal(checkTone('ok'), 'ok')
assert.equal(checkGlyph('warn'), '!')
assert.equal(checkTone('warn'), 'warn')
assert.equal(checkGlyph('info'), 'i')
assert.equal(checkTone('info'), 'info')
assert.equal(checkGlyph(undefined), 'i')
assert.equal(checkTone(undefined), 'muted')
})
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Zeilen der Abo-Angebote (Filter, Aktionen, Mail-Zeile) als reine Helfer
const { offerStatus, filterOffers, offerRowActions, offerMailLine } = await loadModule('src/lib/offerFormat.js')
test('offerStatus: Tonnamen und Beschriftungen sind festgelegt', () => {
assert.deepEqual(offerStatus('offen'), { tone: 'warn', label: 'offen' })
assert.deepEqual(offerStatus('aktiv'), { tone: 'ok', label: 'aktiv' })
assert.deepEqual(offerStatus('abgelaufen'), { tone: 'muted', label: 'abgelaufen' })
assert.deepEqual(offerStatus('zurueckgezogen'), { tone: 'muted', label: 'zurückgezogen' })
})
test('filterOffers: "open" zeigt offen und aktiv, "all" alles, ohne Liste leer, Eingabe bleibt unveraendert', () => {
const offers = [
{ id: '1', status: 'offen' },
{ id: '2', status: 'aktiv' },
{ id: '3', status: 'abgelaufen' },
{ id: '4', status: 'zurueckgezogen' },
]
const copy = structuredClone(offers)
assert.deepEqual(filterOffers(offers, 'open').map((o) => o.id), ['1', '2'])
assert.deepEqual(filterOffers(offers, 'all').map((o) => o.id), ['1', '2', '3', '4'])
assert.deepEqual(filterOffers(null, 'open'), [])
assert.deepEqual(filterOffers(null, 'all'), [])
assert.deepEqual(offers, copy)
})
test('offerRowActions: E-Mail-Knopf und Zurueckziehen je nach Status', () => {
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'offen', mailSendable: true, notifiedAt: '2026-09-30T10:05:00.000Z' }), { sendLabel: 'Erneut senden', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'aktiv', mailSendable: true }), { sendLabel: 'E-Mail senden', canWithdraw: false })
assert.deepEqual(offerRowActions({ status: 'abgelaufen', mailSendable: false }), { sendLabel: '', canWithdraw: true })
assert.deepEqual(offerRowActions({ status: 'zurueckgezogen' }), { sendLabel: '', canWithdraw: false })
})
test('offerMailLine: gesendet, nicht verschickt, ohne Adresse, sonst keine Zeile', () => {
assert.deepEqual(offerMailLine({ notifiedAt: '2026-09-30T10:05:00.000Z' }), { tone: 'muted', text: 'E-Mail gesendet 30.09.26, 12:05' })
assert.deepEqual(offerMailLine({ mailSendable: true }), { tone: 'warn', text: 'E-Mail nicht verschickt' })
assert.deepEqual(offerMailLine({ status: 'offen', customerEmail: '' }), { tone: 'warn', text: 'Keine E-Mail-Adresse hinterlegt' })
assert.equal(offerMailLine({ status: 'abgelaufen', customerEmail: '' }), null)
assert.equal(offerMailLine({ status: 'offen', customerEmail: 'a@b.de' }), null)
})
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// P6: Faelligkeitsspalte der E-Mail-Speicher-Aufgaben als reiner Helfer
const { storageDue, isOverdue } = await loadModule('src/lib/storageFormat.js')
const t0 = Date.parse('2026-10-05T08:00:00Z')
test('storageDue: Einrichten sofort, Verringern zum Periodenende (ueberfaellig = warn)', () => {
assert.deepEqual(
storageDue({ kind: 'einrichten', createdAt: '2026-10-01T08:30:00.000Z' }, t0),
{ tone: 'warn', label: 'jetzt einrichten', sub: 'bestellt 01.10.26, 10:30', subMuted: true },
)
assert.deepEqual(
storageDue({ kind: 'senken', dueAt: '2026-10-04T00:00:00.000Z' }, t0),
{ tone: 'warn', label: 'zum Periodenende', sub: '04.10.2026', subMuted: false },
)
const later = storageDue({ kind: 'senken', dueAt: '2026-10-20T00:00:00.000Z' }, t0)
assert.equal(later.tone, 'muted')
assert.equal(later.sub, '20.10.2026')
})
test('isOverdue: vergangenes dueAt ja, zukuenftiges oder fehlendes nein', () => {
assert.equal(isOverdue({ dueAt: '2026-10-04T00:00:00.000Z' }, t0), true)
assert.equal(isOverdue({ dueAt: '2026-10-20T00:00:00.000Z' }, t0), false)
assert.equal(isOverdue({}, t0), false)
})