Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 0069a62a09
commit 88a98b858f
10 files changed
+1001 -111

No files matched your search

+41
View File
@@ -84,6 +84,15 @@ export function sinceLabel(iso, now = Date.now()) {
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
/** Berliner Kalendertag ('YYYY-MM-DD') eines ISO-Zeitpunkts, Date oder Millisekundenwerts; ungueltig -> null. */
export function ymdBerlin(value) {
const t =
value instanceof Date ? value.getTime() : typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
if (!Number.isFinite(t)) return null
const parts = Object.fromEntries(berlinDate.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
return `${parts.year}-${parts.month}-${parts.day}`
}
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
@@ -140,6 +149,38 @@ export const AGE_GROUP_LABEL = {
'ueber-60': 'Über 60 Tage',
}
export const INTERVAL_LABEL = {
monat: 'monatlich',
quartal: 'vierteljährlich',
jahr: 'jährlich',
}
const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 }
function daysInMonth(year, month) {
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
return [4, 6, 9, 11].includes(month) ? 30 : 31
}
/**
* Naechster Termin einer Folge: '2026-01-31' + 'monat' -> '2026-02-28' (Monatsende wird begrenzt, nie in den
* Folgemonat uebergelaufen). Rhythmen: monat, quartal, jahr. Ungueltiges Datum oder unbekannter Rhythmus -> null.
*/
export function nextIntervalDate(ymd, interval) {
const months = Object.prototype.hasOwnProperty.call(INTERVAL_MONTHS, interval) ? INTERVAL_MONTHS[interval] : 0
const m = typeof ymd === 'string' ? DATE_ONLY.exec(ymd.trim()) : null
if (!months || !m) return null
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
const total = year * 12 + (month - 1) + months
const nextYear = Math.floor(total / 12)
const nextMonth = (total % 12) + 1
const nextDay = Math.min(day, daysInMonth(nextYear, nextMonth))
return `${String(nextYear).padStart(4, '0')}-${String(nextMonth).padStart(2, '0')}-${String(nextDay).padStart(2, '0')}`
}
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
export function pageOf(list, page, size = 50) {
const items = Array.isArray(list) ? list : []
+218
View File
@@ -0,0 +1,218 @@
// Reine Darstellungs-Helfer fuer Statuszeile, Liquiditaetskopf und Aufgabenliste der Finanzen-Seite
// (kein React, kein Appwrite, unter Node testbar). Beträge sind Cent, Texte deutsch.
import {
INTERVAL_LABEL,
dateDe,
euro,
euroWhole,
nextIntervalDate,
signedEuro,
sinceLabel,
ymdBerlin,
URGENCY,
} from './financeFormat'
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
const text = (v) => (typeof v === 'string' ? v.trim() : '')
/** Id des Rechnungsblocks (Task 5): Ziel für „Zu den Rechnungen“. */
export const INVOICES_ANCHOR = 'finanzen-rechnungen'
/** Wie viele Aufgaben ohne „Alle anzeigen“ sichtbar sind. */
export const TODO_LIMIT = 7
// --- Statuszeile (Block 0) ---------------------------------------------------------------------------------
/**
* Bank-Teil der Statuszeile: { ok, text }. ok=false zeigt eine Warnung (Text steht immer dabei).
* Normal: „Bank: Stand vor 3 Std. · jüngste Buchung 29.09.2026 · Zugang bis 29.03.2027“.
*/
export function bankStatus(bank, now = Date.now()) {
if (!bank || typeof bank !== 'object') return { ok: false, text: 'Bank: nicht verbunden' }
const until = dateDe(bank.validUntil)
const untilKnown = until !== '–'
const validDay = ymdBerlin(bank.validUntil)
const today = ymdBerlin(now)
const expired = bank.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
if (expired) return { ok: false, text: `Bank: Zugang abgelaufen${untilKnown ? ` am ${until}` : ''}` }
const parts = []
if (bank.lastSyncOk === false || bank.status === 'fehler') {
const error = text(bank.lastError)
parts.push(`Abruf fehlgeschlagen${error ? `: ${error}` : ''}`)
if (untilKnown) parts.push(`Zugang bis ${until}`)
return { ok: false, text: `Bank: ${parts.join(' · ')}` }
}
parts.push(text(bank.lastSyncAt) ? `Stand ${sinceLabel(bank.lastSyncAt, now)}` : 'noch kein Abruf')
if (text(bank.latestBookingDate)) parts.push(`jüngste Buchung ${dateDe(bank.latestBookingDate)}`)
if (untilKnown) parts.push(`Zugang bis ${until}`)
return { ok: true, text: `Bank: ${parts.join(' · ')}` }
}
/** Stripe-Teil der Statuszeile: { ok, text }; bei Ausfall mit dem Text des Servers. */
export function stripeStatus(stripe, now = Date.now()) {
if (!stripe || typeof stripe !== 'object') return { ok: true, text: 'Stripe: Stand unbekannt' }
if (stripe.ok === false) {
return {
ok: false,
text: `Stripe: Abruf fehlgeschlagen: ${text(stripe.fehler) || 'Stripe ist gerade nicht erreichbar.'}`,
}
}
const since = text(stripe.abgerufenUm) ? sinceLabel(stripe.abgerufenUm, now) : '–'
return { ok: true, text: `Stripe: Stand ${since === '–' ? 'unbekannt' : since}` }
}
/** [{ key, ok, text }] für Bank und Stripe. */
export function statusItems(stand, now = Date.now()) {
const s = stand && typeof stand === 'object' ? stand : {}
return [
{ key: 'bank', ...bankStatus(s.bank, now) },
{ key: 'stripe', ...stripeStatus(s.stripe, now) },
]
}
// --- Liquiditätskopf (Block 1) -----------------------------------------------------------------------------
/**
* Aufschlüsselung „Verfügbar nach Rücklagen“ als Zeilen { key, label, text, total }:
* Kontostand − Steuer-Rücklage − Fixkosten 30 Tage − Mindestpuffer = Verfügbar (Beträge auf den Cent genau).
*/
export function breakdownRows(aufschluesselung, verfuegbarCents) {
const a = aufschluesselung && typeof aufschluesselung === 'object' ? aufschluesselung : {}
const minus = (cents) => (isNum(cents) ? signedEuro(-cents) : '–')
return [
{ key: 'kontostand', label: 'Kontostand (gebucht)', text: euro(a.kontostandCents), total: false },
{ key: 'steuer', label: 'Steuer-Rücklage', text: minus(a.steuerRuecklageCents), total: false },
{ key: 'fixkosten', label: 'Fixkosten der nächsten 30 Tage', text: minus(a.fixkosten30Cents), total: false },
{ key: 'puffer', label: 'Mindestpuffer', text: minus(a.mindestpufferCents), total: false },
{ key: 'verfuegbar', label: 'Verfügbar nach Rücklagen', text: euro(verfuegbarCents), total: true },
]
}
/**
* Zwei Zeilen „Tiefster Stand in 30 Tagen“: sicher und erwartet, je { key, label, amountText, dateText, negative };
* ein Eintrag ist null, wenn der Server keinen Wert liefert (kein Kontostand).
*/
export function lowPointLines(kopf) {
const k = kopf && typeof kopf === 'object' ? kopf : {}
const line = (key, label, point) =>
point && isNum(point.amountCents)
? { key, label, amountText: euroWhole(point.amountCents), dateText: dateDe(point.date), negative: point.amountCents < 0 }
: null
return [line('sicher', 'sicher', k.tiefsterSicher), line('erwartet', 'erwartet', k.tiefsterErwartet)]
}
// --- Zu erledigen (Block 2) --------------------------------------------------------------------------------
/** Nur Aufgaben (Objekte) in Serverreihenfolge; bei eingeklappter Liste höchstens `max` Stück. */
export function visibleTodos(aufgaben, expanded = false, max = TODO_LIMIT) {
const items = Array.isArray(aufgaben) ? aufgaben.filter((t) => t && typeof t === 'object') : []
if (expanded || items.length <= max) return { shown: items, total: items.length, hidden: 0 }
return { shown: items.slice(0, max), total: items.length, hidden: items.length - max }
}
export function urgencyOf(todo) {
return URGENCY[todo?.dringlichkeit] || URGENCY.info
}
/** Stabiler Schlüssel einer Aufgabe (für React und den Zustand „läuft gerade“). */
export function todoKey(todo, index = 0) {
const a = todo?.aktion || {}
const ref = a.transactionId || a.invoiceId || a.customerId || a.vorschlag?.key || ''
return [index, todo?.art || '', todo?.datum || '', ref].join('|')
}
/** http(s)-Adresse oder ''; schützt vor `javascript:` und Ähnlichem aus Serverdaten. */
export function safeUrl(value) {
if (typeof value !== 'string' || !value.trim()) return ''
try {
const url = new URL(value.trim())
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''
} catch {
return ''
}
}
/** Beschriftung des einen Knopfs einer Aufgabe; '' (kein Knopf) bei unbekannter Aktion. */
export function todoButtonLabel(todo) {
const a = todo?.aktion
switch (a?.typ) {
case 'bank-verbinden':
return 'Bank verbinden'
case 'bank-aktualisieren':
return 'Aktualisieren'
case 'rechnung-oeffnen':
return safeUrl(a.hostedUrl) ? 'Zahlungsseite öffnen' : 'Zu den Rechnungen'
case 'zuordnen':
return 'Zuordnen'
case 'kunde-oeffnen':
return 'Kunde öffnen'
case 'fixkosten-uebernehmen':
return 'Übernehmen'
case 'einstellungen':
return 'Einstellungen'
default:
return ''
}
}
/**
* Fixkosten-Vorschlag des Servers ({ key, name, amountCents, interval, lastDate }) -> Anfrage für createFixedCost
* samt Rückfrage-Text; null, wenn der Vorschlag unvollständig ist. nextDate = lastDate + ein Rhythmus.
*/
export function fixedCostFromSuggestion(vorschlag) {
const v = vorschlag && typeof vorschlag === 'object' ? vorschlag : {}
const name = text(v.name)
const nextDate = nextIntervalDate(v.lastDate, v.interval)
if (!name || !Number.isSafeInteger(v.amountCents) || v.amountCents <= 0 || !nextDate) return null
return {
payload: {
name,
amountCents: v.amountCents,
interval: v.interval,
nextDate,
counterpartyMatch: typeof v.key === 'string' ? v.key : '',
},
confirmText: `Als Fixkosten übernehmen: ${name}, ${euro(v.amountCents)} ${INTERVAL_LABEL[v.interval]}?`,
}
}
const planError = (message) => ({ kind: 'error', message })
/**
* Was ein Klick auf den Aufgaben-Knopf bewirkt (die Seite führt es aus):
* bank-connect | bank-refresh | open-url {url} | scroll {id} | assign {transactionId} | navigate {to} |
* fixed-cost {payload, confirmText} | error {message}.
*/
export function planTodoAction(aktion) {
const a = aktion && typeof aktion === 'object' ? aktion : {}
switch (a.typ) {
case 'bank-verbinden':
return { kind: 'bank-connect' }
case 'bank-aktualisieren':
return { kind: 'bank-refresh' }
case 'rechnung-oeffnen': {
const url = safeUrl(a.hostedUrl)
return url ? { kind: 'open-url', url } : { kind: 'scroll', id: INVOICES_ANCHOR }
}
case 'zuordnen':
return text(a.transactionId)
? { kind: 'assign', transactionId: a.transactionId }
: planError('Die Buchung ist nicht bekannt.')
case 'kunde-oeffnen':
return text(a.customerId)
? { kind: 'navigate', to: `/customers/${encodeURIComponent(a.customerId)}` }
: planError('Zu diesem Eintrag ist kein Kunde hinterlegt.')
case 'einstellungen':
return { kind: 'navigate', to: '/finance/einstellungen' }
case 'fixkosten-uebernehmen': {
const suggestion = fixedCostFromSuggestion(a.vorschlag)
return suggestion
? { kind: 'fixed-cost', ...suggestion }
: planError('Der Vorschlag ist unvollständig und kann nicht übernommen werden.')
}
default:
return planError('Diese Aktion ist nicht bekannt.')
}
}