Files
FEDEO/frontend/pages/accounting/liquidity.vue
florianfederspiel aaf91ea15e Belege und Bankmuster in Liquiditätsprognose verwalten
Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
2026-04-23 16:03:37 +02:00

350 lines
11 KiB
Vue

<script setup>
import dayjs from "dayjs"
import { Line } from "vue-chartjs"
const toast = useToast()
const tempStore = useTempStore()
const forecast = ref(null)
const loading = ref(true)
const error = ref("")
const dismissedRecurringKeys = computed(() => {
return tempStore.settings?.liquidityForecast?.dismissedRecurringKeys || []
})
const storeDismissedRecurringKeys = (keys) => {
tempStore.modifySettings("liquidityForecast", {
...(tempStore.settings?.liquidityForecast || {}),
dismissedRecurringKeys: [...new Set(keys)].filter(Boolean)
})
}
const sourceLabels = {
open_createddocument: "Offene Ausgangsrechnung",
open_incominginvoice: "Offener Eingangsbeleg",
recurring_bankstatement: "Regelmäßige Bankbewegung"
}
const intervalLabels = {
weekly: "wöchentlich",
monthly: "monatlich",
quarterly: "quartalsweise",
yearly: "jährlich"
}
const loadForecast = async () => {
loading.value = true
error.value = ""
try {
forecast.value = await useFunctions().useLiquidityForecast(dismissedRecurringKeys.value)
} catch (err) {
error.value = "Die Liquiditätsprognose konnte nicht geladen werden."
toast.add({
title: "Fehler",
description: error.value,
color: "error"
})
} finally {
loading.value = false
}
}
const formatDate = (value) => dayjs(value).format("DD.MM.YYYY")
const getEventRoute = (event) => {
if (event.source === "open_createddocument" && event.sourceId) return `/createDocument/show/${event.sourceId}`
if (event.source === "open_incominginvoice" && event.sourceId) return `/incomingInvoices/show/${event.sourceId}`
return null
}
const openEvent = (event) => {
const route = getEventRoute(event)
if (route) navigateTo(route)
}
const dismissRecurringKey = async (key) => {
if (!key) return
storeDismissedRecurringKeys([...dismissedRecurringKeys.value, key])
toast.add({
title: "Bankbewegung abgeschlossen",
description: "Das erkannte Muster wird aus der Liquiditätsprognose entfernt.",
color: "success"
})
await loadForecast()
}
const restoreDismissedRecurring = async () => {
storeDismissedRecurringKeys([])
toast.add({
title: "Bankbewegungen wiederhergestellt",
description: "Ausgeblendete Muster werden wieder in der Prognose berücksichtigt.",
color: "success"
})
await loadForecast()
}
const chartData = computed(() => ({
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
datasets: [
{
label: "Prognostizierter Kontostand",
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.12)",
pointRadius: 0,
pointHitRadius: 8,
tension: 0.28,
fill: true,
data: (forecast.value?.points || []).map((point) => point.balance)
},
{
label: "Warnschwelle 0 €",
borderColor: "#ef4444",
borderDash: [6, 6],
pointRadius: 0,
data: (forecast.value?.points || []).map(() => 0)
}
]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false
},
plugins: {
legend: {
display: true
}
},
scales: {
y: {
ticks: {
callback: (value) => useCurrency(Number(value))
}
}
}
}
const upcomingEvents = computed(() => {
return [...(forecast.value?.events || [])]
.sort((a, b) => a.date.localeCompare(b.date) || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 18)
})
const riskTone = computed(() => {
if (!forecast.value) return "neutral"
if (forecast.value.lowestBalance < 0) return "danger"
if (forecast.value.lowestBalance < forecast.value.startingBalance * 0.15) return "warning"
return "positive"
})
loadForecast()
</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>
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
<UAlert
v-if="error"
color="error"
icon="i-heroicons-exclamation-triangle"
:title="error"
/>
<div v-if="loading" class="flex min-h-[360px] items-center justify-center text-sm text-gray-500">
Liquiditätsprognose wird erstellt...
</div>
<template v-else-if="forecast">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard>
<p class="text-sm text-gray-500">Aktuelle Liquidität</p>
<p class="mt-2 text-2xl font-semibold">{{ useCurrency(forecast.startingBalance) }}</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">Prognose in {{ forecast.horizonDays }} Tagen</p>
<p
class="mt-2 text-2xl font-semibold"
:class="forecast.endingBalance < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(forecast.endingBalance) }}
</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">Niedrigster Stand</p>
<p
class="mt-2 text-2xl font-semibold"
:class="forecast.lowestBalance < 0 ? 'text-rose-600' : 'text-orange-500'"
>
{{ useCurrency(forecast.lowestBalance) }}
</p>
<p class="mt-1 text-xs text-gray-500">{{ formatDate(forecast.lowestBalanceDate) }}</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">KI-Erkennung</p>
<p class="mt-2 text-2xl font-semibold">{{ forecast.recurring.length }}</p>
<p class="mt-1 text-xs text-gray-500">
{{ forecast.ai?.enabled ? `${forecast.ai.candidates} KI-Kandidaten berücksichtigt` : "Fallback ohne KI-Key genutzt" }}
</p>
</UCard>
</div>
<UAlert
: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'))}.`"
/>
<UCard>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="font-semibold">Liquiditätsverlauf</h2>
<p class="text-sm text-gray-500">Täglicher prognostizierter Kontostand</p>
</div>
<div class="flex gap-3 text-sm">
<span class="text-primary-600">+ {{ useCurrency(forecast.totalIncome) }}</span>
<span class="text-rose-600">{{ useCurrency(forecast.totalExpense) }}</span>
</div>
</div>
</template>
<div class="h-[360px]">
<Line :data="chartData" :options="chartOptions" />
</div>
</UCard>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Nächste Zahlungsereignisse</h2>
<p class="text-sm text-gray-500">Offene Belege und prognostizierte regelmäßige Bewegungen</p>
</div>
</template>
<div v-if="upcomingEvents.length" class="divide-y divide-gray-200 dark:divide-gray-800">
<div
v-for="event in upcomingEvents"
:key="`${event.source}-${event.sourceId || event.label}-${event.date}-${event.amount}`"
class="grid gap-3 py-3 sm:grid-cols-[110px_minmax(0,1fr)_auto_auto]"
>
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] || event.source }}</p>
</div>
<span
class="text-right font-semibold"
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(event.amount) }}
</span>
<div class="flex justify-end gap-1">
<UButton
v-if="getEventRoute(event)"
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
<UButton
v-if="event.source === 'recurring_bankstatement' && event.recurringKey"
size="xs"
color="gray"
variant="ghost"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(event.recurringKey)"
>
Abschließen
</UButton>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Keine Zahlungsereignisse im Prognosezeitraum erkannt.</p>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Regelmäßige Bewegungen</h2>
<p class="text-sm text-gray-500">Aus Bankumsätzen erkannt, inklusive KI-Analyse</p>
</div>
</template>
<div v-if="forecast.recurring.length" class="space-y-3">
<div
v-for="item in forecast.recurring"
:key="`${item.label}-${item.amount}-${item.nextDate}`"
class="rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-medium">{{ item.label }}</p>
<p class="text-xs text-gray-500">
{{ intervalLabels[item.interval] || item.interval }} ab {{ formatDate(item.nextDate) }}
</p>
</div>
<span
class="text-right font-semibold"
:class="item.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(item.amount) }}
</span>
</div>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-gray-500">
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
</p>
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(item.key)"
>
Als abgeschlossen entfernen
</UButton>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Noch keine regelmäßigen Bewegungen erkannt.</p>
</UCard>
</div>
</template>
</UDashboardPanelContent>
</template>