diff --git a/frontend/pages/accounting/liquidity.vue b/frontend/pages/accounting/liquidity.vue index c6501dc..eea6991 100644 --- a/frontend/pages/accounting/liquidity.vue +++ b/frontend/pages/accounting/liquidity.vue @@ -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() +})