Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0069a62a09
commit
88a98b858f
10 files changed
+1001
-111
No files matched your search
@@ -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 : []
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user