Liquiditätsprognose zwischenspeichern
Lädt die Liquiditätsprognose aus einem lokalen Cache und erstellt sie nur noch manuell über den Refresh-Button in der Toolbar neu.
This commit is contained in:
@@ -6,8 +6,12 @@ const toast = useToast()
|
||||
const tempStore = useTempStore()
|
||||
|
||||
const forecast = ref(null)
|
||||
const loading = ref(true)
|
||||
const rawForecast = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref("")
|
||||
const cacheLoaded = ref(false)
|
||||
|
||||
const CACHE_KEY = "fedeo:liquidityForecast:v1"
|
||||
|
||||
const dismissedRecurringKeys = computed(() => {
|
||||
return tempStore.settings?.liquidityForecast?.dismissedRecurringKeys || []
|
||||
@@ -33,14 +37,109 @@ const intervalLabels = {
|
||||
yearly: "jährlich"
|
||||
}
|
||||
|
||||
const loadForecast = async () => {
|
||||
const roundMoney = (value) => Number(Number(value || 0).toFixed(2))
|
||||
|
||||
const saveForecastCache = (value) => {
|
||||
if (!import.meta.client || !value) return
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||
cachedAt: new Date().toISOString(),
|
||||
forecast: value
|
||||
}))
|
||||
}
|
||||
|
||||
const readForecastCache = () => {
|
||||
if (!import.meta.client) return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const applyDismissedRecurring = (value) => {
|
||||
if (!value) return null
|
||||
|
||||
const dismissed = new Set(dismissedRecurringKeys.value)
|
||||
const events = (value.events || []).filter((event) => {
|
||||
return event.source !== "recurring_bankstatement" || !event.recurringKey || !dismissed.has(event.recurringKey)
|
||||
})
|
||||
const recurring = (value.recurring || []).filter((item) => !item.key || !dismissed.has(item.key))
|
||||
const eventsByDate = new Map()
|
||||
|
||||
events.forEach((event) => {
|
||||
if (!eventsByDate.has(event.date)) eventsByDate.set(event.date, [])
|
||||
eventsByDate.get(event.date).push(event)
|
||||
})
|
||||
|
||||
let runningBalance = Number(value.startingBalance || 0)
|
||||
const points = (value.points || []).map((point) => {
|
||||
const dayEvents = [...(eventsByDate.get(point.date) || [])].sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount))
|
||||
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
|
||||
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
|
||||
|
||||
runningBalance = roundMoney(runningBalance + income + expense)
|
||||
|
||||
return {
|
||||
...point,
|
||||
balance: runningBalance,
|
||||
income,
|
||||
expense,
|
||||
events: dayEvents
|
||||
}
|
||||
})
|
||||
|
||||
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0] || { balance: value.startingBalance, date: value.generatedAt })
|
||||
|
||||
return {
|
||||
...value,
|
||||
recurring,
|
||||
events,
|
||||
points,
|
||||
endingBalance: points[points.length - 1]?.balance || value.startingBalance,
|
||||
lowestBalance: lowestPoint?.balance || value.startingBalance,
|
||||
lowestBalanceDate: lowestPoint?.date || value.generatedAt,
|
||||
totalIncome: roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + Number(event.amount || 0), 0)),
|
||||
totalExpense: roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
|
||||
}
|
||||
}
|
||||
|
||||
const setRawForecast = (value) => {
|
||||
rawForecast.value = value
|
||||
forecast.value = applyDismissedRecurring(value)
|
||||
}
|
||||
|
||||
const loadForecastFromCache = () => {
|
||||
const cached = readForecastCache()
|
||||
if (cached?.forecast) {
|
||||
setRawForecast({
|
||||
...cached.forecast,
|
||||
cachedAt: cached.cachedAt
|
||||
})
|
||||
}
|
||||
|
||||
cacheLoaded.value = true
|
||||
}
|
||||
|
||||
const refreshForecast = async () => {
|
||||
loading.value = true
|
||||
error.value = ""
|
||||
|
||||
try {
|
||||
forecast.value = await useFunctions().useLiquidityForecast(dismissedRecurringKeys.value)
|
||||
const nextForecast = await useFunctions().useLiquidityForecast()
|
||||
saveForecastCache(nextForecast)
|
||||
setRawForecast({
|
||||
...nextForecast,
|
||||
cachedAt: new Date().toISOString()
|
||||
})
|
||||
toast.add({
|
||||
title: "Prognose aktualisiert",
|
||||
description: "Das neue Ergebnis wurde zwischengespeichert.",
|
||||
color: "success"
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
|
||||
error.value = "Die Liquiditätsprognose konnte nicht neu erstellt werden."
|
||||
toast.add({
|
||||
title: "Fehler",
|
||||
description: error.value,
|
||||
@@ -68,24 +167,29 @@ const dismissRecurringKey = async (key) => {
|
||||
if (!key) return
|
||||
|
||||
storeDismissedRecurringKeys([...dismissedRecurringKeys.value, key])
|
||||
forecast.value = applyDismissedRecurring(rawForecast.value)
|
||||
toast.add({
|
||||
title: "Bankbewegung abgeschlossen",
|
||||
description: "Das erkannte Muster wird aus der Liquiditätsprognose entfernt.",
|
||||
color: "success"
|
||||
})
|
||||
await loadForecast()
|
||||
}
|
||||
|
||||
const restoreDismissedRecurring = async () => {
|
||||
const restoreDismissedRecurring = () => {
|
||||
storeDismissedRecurringKeys([])
|
||||
forecast.value = applyDismissedRecurring(rawForecast.value)
|
||||
toast.add({
|
||||
title: "Bankbewegungen wiederhergestellt",
|
||||
description: "Ausgeblendete Muster werden wieder in der Prognose berücksichtigt.",
|
||||
color: "success"
|
||||
})
|
||||
await loadForecast()
|
||||
}
|
||||
|
||||
const cachedAtLabel = computed(() => {
|
||||
const cachedAt = forecast.value?.cachedAt || forecast.value?.generatedAt
|
||||
return cachedAt ? formatDate(cachedAt) + " " + dayjs(cachedAt).format("HH:mm") : null
|
||||
})
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
|
||||
datasets: [
|
||||
@@ -143,30 +247,46 @@ const riskTone = computed(() => {
|
||||
return "positive"
|
||||
})
|
||||
|
||||
loadForecast()
|
||||
watch(dismissedRecurringKeys, () => {
|
||||
forecast.value = applyDismissedRecurring(rawForecast.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadForecastFromCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Liquiditätsprognose">
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
:loading="loading"
|
||||
variant="soft"
|
||||
@click="loadForecast"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="dismissedRecurringKeys.length"
|
||||
icon="i-heroicons-eye"
|
||||
variant="ghost"
|
||||
@click="restoreDismissedRecurring"
|
||||
>
|
||||
Ausgeblendete wiederherstellen
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UDashboardNavbar title="Liquiditätsprognose" />
|
||||
|
||||
<UDashboardToolbar>
|
||||
<div class="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span v-if="cachedAtLabel">Zwischengespeichert am {{ cachedAtLabel }}</span>
|
||||
<span v-else>Noch keine gespeicherte Prognose vorhanden</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
v-if="dismissedRecurringKeys.length"
|
||||
icon="i-heroicons-eye"
|
||||
variant="ghost"
|
||||
@click="restoreDismissedRecurring"
|
||||
>
|
||||
Ausgeblendete wiederherstellen
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
:loading="loading"
|
||||
variant="soft"
|
||||
@click="refreshForecast"
|
||||
>
|
||||
Prognose neu erstellen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
|
||||
<UAlert
|
||||
@@ -180,6 +300,21 @@ loadForecast()
|
||||
Liquiditätsprognose wird erstellt...
|
||||
</div>
|
||||
|
||||
<UCard v-else-if="cacheLoaded && !forecast">
|
||||
<div class="flex min-h-[280px] flex-col items-center justify-center gap-4 text-center">
|
||||
<UIcon name="i-heroicons-chart-bar-square" class="size-12 text-gray-300" />
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Noch keine Prognose gespeichert</h2>
|
||||
<p class="mt-1 max-w-xl text-sm text-gray-500">
|
||||
Erstelle die Liquiditätsprognose bewusst über den Refresh-Button. Das Ergebnis bleibt danach zwischengespeichert und wird beim nächsten Öffnen sofort angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-arrow-path" :loading="loading" @click="refreshForecast">
|
||||
Prognose neu erstellen
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<template v-else-if="forecast">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UCard>
|
||||
@@ -221,7 +356,7 @@ loadForecast()
|
||||
:color="riskTone === 'danger' ? 'error' : riskTone === 'warning' ? 'warning' : 'success'"
|
||||
:icon="riskTone === 'danger' ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-chart-bar-square'"
|
||||
:title="riskTone === 'danger' ? 'Liquiditätsengpass möglich' : riskTone === 'warning' ? 'Liquidität wird knapp' : 'Liquidität bleibt positiv'"
|
||||
:description="`Die Prognose kombiniert aktuelle Kontostände, offene Belege und erkannte regelmäßige Bankbewegungen bis ${formatDate(dayjs().add(forecast.horizonDays, 'day'))}.`"
|
||||
:description="`Die gespeicherte Prognose kombiniert aktuelle Kontostände, offene Belege und erkannte regelmäßige Bankbewegungen bis ${formatDate(dayjs().add(forecast.horizonDays, 'day'))}. Neu berechnet wird sie nur über den Refresh-Button.`"
|
||||
/>
|
||||
|
||||
<UCard>
|
||||
|
||||
Reference in New Issue
Block a user