diff --git a/src/lib/creditFormat.js b/src/lib/creditFormat.js index 3025455..8ed38ac 100644 --- a/src/lib/creditFormat.js +++ b/src/lib/creditFormat.js @@ -10,6 +10,17 @@ export const CREDIT_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 = { eingereicht: 'Antrag eingereicht', rueckfrage: 'Rückfrage an den Kunden', diff --git a/src/lib/offerFormat.js b/src/lib/offerFormat.js index 1770b6d..9779154 100644 --- a/src/lib/offerFormat.js +++ b/src/lib/offerFormat.js @@ -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. // "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 } +/** 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()) export function offerPreviewText(price, months, regular, running) { diff --git a/src/lib/storageFormat.js b/src/lib/storageFormat.js index 6ffe0b3..5c89bdb 100644 --- a/src/lib/storageFormat.js +++ b/src/lib/storageFormat.js @@ -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. @@ -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) +/** 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) => `${storageLabel(f.fromUnits)} → ${storageLabel(f.toUnits)} – Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${f.error}` : ''}` diff --git a/tests/unit/creditFormat.test.mjs b/tests/unit/creditFormat.test.mjs new file mode 100644 index 0000000..ab149f2 --- /dev/null +++ b/tests/unit/creditFormat.test.mjs @@ -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') +}) diff --git a/tests/unit/offerFormat.test.mjs b/tests/unit/offerFormat.test.mjs new file mode 100644 index 0000000..0c4e45d --- /dev/null +++ b/tests/unit/offerFormat.test.mjs @@ -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) +}) diff --git a/tests/unit/storageFormat.test.mjs b/tests/unit/storageFormat.test.mjs new file mode 100644 index 0000000..62fe942 --- /dev/null +++ b/tests/unit/storageFormat.test.mjs @@ -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) +})