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.
265 lines
8.7 KiB
Vue
265 lines
8.7 KiB
Vue
<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>
|