Liquiditätsprognose für Auswertungen ergänzt
Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
This commit is contained in:
@@ -141,7 +141,7 @@ const links = computed(() => {
|
||||
to: "/accounting/depreciation",
|
||||
icon: "i-heroicons-calendar-days",
|
||||
} : null,
|
||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? {
|
||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres") || featureEnabled("banking")) ? {
|
||||
label: "Auswertungen",
|
||||
icon: "i-heroicons-chart-pie",
|
||||
defaultOpen: false,
|
||||
@@ -156,6 +156,11 @@ const links = computed(() => {
|
||||
to: "/accounting/bwa",
|
||||
icon: "i-heroicons-chart-bar-square",
|
||||
} : null,
|
||||
featureEnabled("banking") ? {
|
||||
label: "Liquidität",
|
||||
to: "/accounting/liquidity",
|
||||
icon: "i-heroicons-banknotes",
|
||||
} : null,
|
||||
featureEnabled("costcentres") ? {
|
||||
label: "Kostenstellen",
|
||||
to: "/standardEntity/costcentres",
|
||||
|
||||
@@ -97,5 +97,9 @@ export const useFunctions = () => {
|
||||
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
||||
}
|
||||
|
||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF}
|
||||
const useLiquidityForecast = async () => {
|
||||
return await useNuxtApp().$api("/api/functions/liquidity-forecast")
|
||||
}
|
||||
|
||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
|
||||
}
|
||||
|
||||
264
frontend/pages/accounting/liquidity.vue
Normal file
264
frontend/pages/accounting/liquidity.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs"
|
||||
import { Line } from "vue-chartjs"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const forecast = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref("")
|
||||
|
||||
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()
|
||||
} 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 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>
|
||||
</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]"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
|
||||
</p>
|
||||
</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>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement } from 'chart.js'
|
||||
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js'
|
||||
export default defineNuxtPlugin(() => {
|
||||
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement)
|
||||
})
|
||||
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement, Filler)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user