Files
FEDEO/frontend/pages/accounting/liquidity.vue
florianfederspiel 4783971000 Rechnungsentwürfe optional in Prognose anzeigen
Zeigt Rechnungsentwürfe eingeklammert als optionalen Einfluss in der Liquiditätsprognose an, ohne den eigentlichen Endstand zu verändern.
2026-04-23 21:29:14 +02:00

741 lines
27 KiB
Vue

<script setup>
import dayjs from "dayjs"
import { Line } from "vue-chartjs"
const toast = useToast()
const tempStore = useTempStore()
const forecast = ref(null)
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 || []
})
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",
draft_createddocument: "Rechnungsentwurf"
}
const intervalLabels = {
weekly: "wöchentlich",
monthly: "monatlich",
quarterly: "quartalsweise",
yearly: "jährlich"
}
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 {
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 neu erstellt 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])
forecast.value = applyDismissedRecurring(rawForecast.value)
toast.add({
title: "Bankbewegung abgeschlossen",
description: "Das erkannte Muster wird aus der Liquiditätsprognose entfernt.",
color: "success"
})
}
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"
})
}
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: [
{
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 groupedEventSummary = computed(() => {
const summary = {
open_createddocument: { label: sourceLabels.open_createddocument, amount: 0, count: 0 },
open_incominginvoice: { label: sourceLabels.open_incominginvoice, amount: 0, count: 0 },
recurring_bankstatement: { label: sourceLabels.recurring_bankstatement, amount: 0, count: 0 }
}
;(forecast.value?.events || []).forEach((event) => {
if (!summary[event.source]) return
summary[event.source].amount = roundMoney(summary[event.source].amount + Number(event.amount || 0))
summary[event.source].count += 1
})
return Object.values(summary)
})
const topIncomeDrivers = computed(() => {
return [...(forecast.value?.events || [])]
.filter((event) => Number(event.amount || 0) > 0)
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
.slice(0, 12)
})
const topExpenseDrivers = computed(() => {
return [...(forecast.value?.events || [])]
.filter((event) => Number(event.amount || 0) < 0)
.sort((a, b) => Math.abs(Number(b.amount || 0)) - Math.abs(Number(a.amount || 0)))
.slice(0, 12)
})
const draftIncomeDrivers = computed(() => {
return [...(forecast.value?.draftEvents || [])]
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
.slice(0, 12)
})
const detailedDays = computed(() => {
let previousBalance = Number(forecast.value?.startingBalance || 0)
return (forecast.value?.points || [])
.filter((point) => (point.events || []).length > 0)
.map((point) => {
const startBalance = previousBalance
previousBalance = Number(point.balance || 0)
return {
...point,
startBalance: roundMoney(startBalance)
}
})
.slice(0, 24)
})
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"
})
watch(dismissedRecurringKeys, () => {
forecast.value = applyDismissedRecurring(rawForecast.value)
})
onMounted(() => {
loadForecastFromCache()
})
</script>
<template>
<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
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>
<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>
<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 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.`"
/>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Herleitung</h2>
<p class="text-sm text-gray-500">So entsteht der prognostizierte Endstand</p>
</div>
</template>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Startbestand</span>
<span class="font-semibold">{{ useCurrency(forecast.startingBalance) }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Geplante Einzahlungen</span>
<span class="font-semibold text-primary-600">+ {{ useCurrency(forecast.totalIncome) }}</span>
</div>
<div v-if="forecast.draftIncome" class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Rechnungsentwürfe optional</span>
<span class="font-semibold text-gray-500">(+ {{ useCurrency(forecast.draftIncome) }})</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Geplante Auszahlungen</span>
<span class="font-semibold text-rose-600">{{ useCurrency(forecast.totalExpense) }}</span>
</div>
<div class="border-t border-dashed border-gray-200 pt-3 dark:border-gray-800">
<div class="flex items-center justify-between gap-3">
<span class="font-medium">Erwarteter Endstand</span>
<span class="text-lg font-semibold">{{ useCurrency(forecast.endingBalance) }}</span>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Einflussfaktoren</h2>
<p class="text-sm text-gray-500">Welche Quellen in die Prognose einfließen</p>
</div>
</template>
<div class="divide-y divide-gray-200 dark:divide-gray-800">
<div
v-for="row in groupedEventSummary"
:key="row.label"
class="grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_110px_140px]"
>
<div>
<p class="font-medium">{{ row.label }}</p>
<p class="text-xs text-gray-500">{{ row.count }} Positionen</p>
</div>
<span class="text-sm text-gray-500">{{ row.count }}x</span>
<span
class="text-right font-semibold"
:class="row.amount < 0 ? 'text-rose-600' : row.amount > 0 ? 'text-primary-600' : 'text-gray-500'"
>
{{ useCurrency(row.amount) }}
</span>
</div>
</div>
</UCard>
</div>
<UCard v-if="draftIncomeDrivers.length">
<template #header>
<div>
<h2 class="font-semibold">Rechnungsentwürfe in Klammern</h2>
<p class="text-sm text-gray-500">Optionaler Einfluss, noch nicht im Endstand enthalten</p>
</div>
</template>
<div class="mb-4 rounded-lg border border-dashed border-gray-300 p-4 dark:border-gray-700">
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Optionale Zusatzliquidität aus Entwürfen</span>
<span class="text-lg font-semibold text-gray-600">(+ {{ useCurrency(forecast.draftIncome) }})</span>
</div>
</div>
<div class="space-y-2">
<div
v-for="event in draftIncomeDrivers"
:key="`draft-${event.sourceId || event.label}-${event.date}`"
class="grid gap-3 rounded-lg border border-gray-200 p-3 sm:grid-cols-[120px_minmax(0,1fr)_auto_auto] dark:border-gray-800"
>
<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">Noch nicht gebucht · {{ sourceLabels[event.source] }}</p>
</div>
<span class="text-right font-semibold text-gray-600">(+ {{ useCurrency(event.amount) }})</span>
<UButton
v-if="getEventRoute(event)"
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
</div>
</div>
</UCard>
<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-2">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Größte Einflüsse</h2>
<p class="text-sm text-gray-500">Die wichtigsten Treiber der Prognose</p>
</div>
</template>
<div class="space-y-4">
<div>
<p class="mb-2 text-sm font-medium text-primary-700">Größte Einzahlungen</p>
<div v-if="topIncomeDrivers.length" class="space-y-2">
<div
v-for="event in topIncomeDrivers"
:key="`income-${event.source}-${event.sourceId || event.label}-${event.date}`"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ formatDate(event.date) }} · {{ sourceLabels[event.source] || event.source }}</p>
</div>
<span class="font-semibold text-primary-600">{{ useCurrency(event.amount) }}</span>
</div>
</div>
<p v-else class="text-sm text-gray-500">Keine Einzahlungen im Prognosezeitraum.</p>
</div>
<div>
<p class="mb-2 text-sm font-medium text-rose-700">Größte Auszahlungen</p>
<div v-if="topExpenseDrivers.length" class="space-y-2">
<div
v-for="event in topExpenseDrivers"
:key="`expense-${event.source}-${event.sourceId || event.label}-${event.date}`"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ formatDate(event.date) }} · {{ sourceLabels[event.source] || event.source }}</p>
</div>
<span class="font-semibold text-rose-600">{{ useCurrency(event.amount) }}</span>
</div>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Tagesaufschlüsselung</h2>
<p class="text-sm text-gray-500">Stand vor dem Tag, Bewegungen und neuer Stand</p>
</div>
</template>
<div v-if="detailedDays.length" class="space-y-3">
<div
v-for="day in detailedDays"
:key="day.date"
class="rounded-xl border border-gray-200 p-4 dark:border-gray-800"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="font-medium">{{ formatDate(day.date) }}</p>
<p class="text-xs text-gray-500">Start {{ useCurrency(day.startBalance) }} · Ende {{ useCurrency(day.balance) }}</p>
</div>
<div class="flex gap-3 text-sm">
<span class="text-primary-600">+ {{ useCurrency(day.income) }}</span>
<span class="text-rose-600">{{ useCurrency(day.expense) }}</span>
</div>
</div>
<div class="mt-3 space-y-2">
<div
v-for="event in day.events"
:key="`${day.date}-${event.source}-${event.sourceId || event.label}-${event.amount}`"
class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] || event.source }}</p>
</div>
<span
class="text-right text-sm font-semibold"
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(event.amount) }}
</span>
</div>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Keine einzelnen Tagesbewegungen im Prognosezeitraum vorhanden.</p>
</UCard>
</div>
<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>