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:
1 parent
910fa0ff30
commit
b81e53eef5
6 files changed
+146
-2
No files matched your search
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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}` : ''}`
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user