Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen verteilen den Skonto auf die Restforderung nach Abzug der Abschläge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
973 lines
41 KiB
Vue
973 lines
41 KiB
Vue
<script setup>
|
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
|
import { parseDate } from "@internationalized/date"
|
|
|
|
const {$api, $dayjs} = useNuxtApp()
|
|
const toast = useToast()
|
|
|
|
defineShortcuts({
|
|
'/': () => document.getElementById("searchinput").focus()
|
|
})
|
|
|
|
const tempStore = useTempStore()
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
const bankstatements = ref([])
|
|
const bankaccounts = ref([])
|
|
const customers = ref([])
|
|
const vendors = ref([])
|
|
const entitybankaccounts = ref([])
|
|
const createddocuments = ref([])
|
|
const incominginvoices = ref([])
|
|
const openDocuments = ref([])
|
|
const openIncomingInvoices = ref([])
|
|
const filterAccount = ref([])
|
|
const filterAccountInitialized = ref(false)
|
|
const isSyncing = ref(false)
|
|
const cashbookBookingModalOpen = ref(false)
|
|
const savingCashbookBooking = ref(false)
|
|
const loadingDocs = ref(true) // Startet im Ladezustand
|
|
const loadingSuggestions = ref(true)
|
|
const suggestionsModalOpen = ref(false)
|
|
const selectedSuggestionRowId = ref(null)
|
|
|
|
const CASHBOOK_BANK_ID = "fedeo-cashbook"
|
|
|
|
// Zeitraum-Optionen
|
|
const periodOptions = [
|
|
{label: 'Aktueller Monat', key: 'current_month'},
|
|
{label: 'Letzter Monat', key: 'last_month'},
|
|
{label: 'Aktuelles Quartal', key: 'current_quarter'},
|
|
{label: 'Letztes Quartal', key: 'last_quarter'},
|
|
{label: 'Benutzerdefiniert', key: 'custom'}
|
|
]
|
|
|
|
const bankingFilterItems = [
|
|
{ label: 'Nur offene anzeigen', value: 'Nur offene anzeigen' },
|
|
{ label: 'Nur positive anzeigen', value: 'Nur positive anzeigen' },
|
|
{ label: 'Nur negative anzeigen', value: 'Nur negative anzeigen' }
|
|
]
|
|
|
|
// Initialisierungswerte
|
|
const selectedPeriod = ref(periodOptions[0].key)
|
|
const selectedPeriodOption = computed(() => {
|
|
return periodOptions.find(period => period.key === selectedPeriod.value) || periodOptions[0]
|
|
})
|
|
const dateRange = ref({
|
|
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
|
|
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
|
})
|
|
|
|
const cashbookForm = reactive({
|
|
date: $dayjs().format("YYYY-MM-DD"),
|
|
direction: "expense",
|
|
amount: null
|
|
})
|
|
|
|
const getCalendarValue = (value) => {
|
|
if (!value) return undefined
|
|
|
|
const formatted = $dayjs(value).format('YYYY-MM-DD')
|
|
return formatted ? parseDate(formatted) : undefined
|
|
}
|
|
|
|
const setDateRangeFromCalendar = (field, value) => {
|
|
dateRange.value[field] = value ? value.toString() : ""
|
|
}
|
|
|
|
const getDateButtonLabel = (value) => value ? $dayjs(value).format('DD.MM.YYYY') : 'Kein Datum'
|
|
|
|
const setDateRangeFieldToToday = (field) => {
|
|
dateRange.value[field] = $dayjs().format('YYYY-MM-DD')
|
|
}
|
|
|
|
const loadSuggestionData = async () => {
|
|
loadingSuggestions.value = true
|
|
try {
|
|
const [customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
|
|
useEntities("customers").select(),
|
|
useEntities("vendors").select(),
|
|
useEntities("entitybankaccounts").select(),
|
|
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
|
|
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")
|
|
])
|
|
|
|
customers.value = customerItems
|
|
vendors.value = vendorItems
|
|
entitybankaccounts.value = entityBankItems
|
|
createddocuments.value = documentItems
|
|
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
|
|
|
|
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
|
openDocuments.value = documents.filter(i => useSum().isOpenCreatedDocument(i, createddocuments.value))
|
|
.map(i => ({
|
|
...i,
|
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
|
}))
|
|
|
|
openIncomingInvoices.value = invoiceItems
|
|
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
|
|
} catch (err) {
|
|
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
|
|
} finally {
|
|
loadingSuggestions.value = false
|
|
}
|
|
}
|
|
|
|
const setupPage = async () => {
|
|
loadingDocs.value = true
|
|
const suggestionDataPromise = loadSuggestionData()
|
|
|
|
try {
|
|
const [statements, bankAccountItems] = await Promise.all([
|
|
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
|
|
useEntities("bankaccounts").select()
|
|
])
|
|
|
|
bankstatements.value = statements
|
|
bankaccounts.value = (bankAccountItems || []).map((account) => ({
|
|
...account,
|
|
displayLabel: getBankAccountLabel(account)
|
|
}))
|
|
|
|
const availableAccountIds = bankaccounts.value.map((account) => account.id)
|
|
if (!filterAccountInitialized.value) {
|
|
filterAccount.value = availableAccountIds
|
|
filterAccountInitialized.value = true
|
|
} else {
|
|
filterAccount.value = filterAccount.value
|
|
.map((account) => getBankAccountId(account))
|
|
.filter((id) => availableAccountIds.includes(id))
|
|
}
|
|
|
|
// Erst nach dem Laden der Daten die Store-Werte anwenden
|
|
const savedBanking = tempStore.settings?.['banking'] || {}
|
|
if (savedBanking.periodKey) {
|
|
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
|
|
if (found) selectedPeriod.value = found.key
|
|
}
|
|
if (savedBanking.range) {
|
|
dateRange.value = savedBanking.range
|
|
}
|
|
} catch (err) {
|
|
console.error("Setup Error:", err)
|
|
} finally {
|
|
loadingDocs.value = false
|
|
}
|
|
|
|
void suggestionDataPromise
|
|
}
|
|
|
|
// Watcher für Schnellwahlen & Persistenz
|
|
watch(selectedPeriod, (newPeriod, oldPeriod) => {
|
|
const now = $dayjs()
|
|
|
|
// Nur berechnen, wenn sich die Periode geändert hat
|
|
if (newPeriod !== oldPeriod) {
|
|
switch (newPeriod) {
|
|
case 'current_month':
|
|
dateRange.value = {start: now.startOf('month').format('YYYY-MM-DD'), end: now.endOf('month').format('YYYY-MM-DD')}
|
|
break
|
|
case 'last_month':
|
|
const lastMonth = now.subtract(1, 'month')
|
|
dateRange.value = {start: lastMonth.startOf('month').format('YYYY-MM-DD'), end: lastMonth.endOf('month').format('YYYY-MM-DD')}
|
|
break
|
|
case 'current_quarter':
|
|
dateRange.value = {start: now.startOf('quarter').format('YYYY-MM-DD'), end: now.endOf('quarter').format('YYYY-MM-DD')}
|
|
break
|
|
case 'last_quarter':
|
|
const lastQuarter = now.subtract(1, 'quarter')
|
|
dateRange.value = {start: lastQuarter.startOf('quarter').format('YYYY-MM-DD'), end: lastQuarter.endOf('quarter').format('YYYY-MM-DD')}
|
|
break
|
|
}
|
|
}
|
|
})
|
|
|
|
watch([selectedPeriod, dateRange], () => {
|
|
tempStore.modifyBankingPeriod(selectedPeriod.value, dateRange.value)
|
|
}, { deep: true })
|
|
|
|
const syncBankStatements = async () => {
|
|
isSyncing.value = true
|
|
try {
|
|
const result = await $api('/api/functions/services/bankstatementsync', {method: 'POST'})
|
|
const description = result.accountsFound === 0
|
|
? 'Es sind keine aktiven Bankkonten eingerichtet.'
|
|
: `${result.accountsSynced} Konto/Konten synchronisiert, ${result.transactionsImported} neue Umsätze importiert.`
|
|
toast.add({title: 'Bankdaten synchronisiert', description, color: 'green'})
|
|
await setupPage()
|
|
} catch (error) {
|
|
const description = error?.data?.message || error?.message || 'Fehler beim Abruf.'
|
|
toast.add({title: 'Bank-Synchronisierung fehlgeschlagen', description, color: 'red'})
|
|
} finally {
|
|
isSyncing.value = false
|
|
}
|
|
}
|
|
|
|
const templateColumns = [
|
|
{key: "account", label: "Konto"},
|
|
{key: "valueDate", label: "Valuta"},
|
|
{key: "amount", label: "Betrag"},
|
|
{key: "openAmount", label: "Offen"},
|
|
{key: "partner", label: "Name"},
|
|
{key: "text", label: "Beschreibung"}
|
|
]
|
|
|
|
const searchString = ref(tempStore.searchStrings["bankstatements"] || '')
|
|
const selectedFilters = ref(tempStore.filters?.["banking"]?.["main"] || ['Nur offene anzeigen'])
|
|
|
|
const shouldShowMonthDivider = (row, index) => {
|
|
if (index === 0) return true;
|
|
const prevRow = filteredRows.value[index - 1];
|
|
return $dayjs(getStatementDate(row)).format('MMMM YYYY') !== $dayjs(getStatementDate(prevRow)).format('MMMM YYYY');
|
|
}
|
|
|
|
const calculateOpenSum = (statement) => {
|
|
const allocated = statement.statementallocations?.reduce((acc, curr) => acc + curr.amount, 0) || 0;
|
|
return (statement.amount - allocated).toFixed(2);
|
|
}
|
|
|
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
|
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
|
|
|
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
|
|
|
|
if (invoice.expense) {
|
|
return (sum * -1).toFixed(2)
|
|
} else {
|
|
return sum.toFixed(2)
|
|
}
|
|
}
|
|
|
|
const getStatementDate = (statement) => statement.valueDate || statement.date
|
|
const isCashbookAccount = (account) => account?.bankId === CASHBOOK_BANK_ID
|
|
const getBankAccountId = (account) => typeof account === "object" ? account?.id : account
|
|
const resolveBankAccount = (accountOrId) => {
|
|
if (!accountOrId) return null
|
|
if (typeof accountOrId === "object") return accountOrId
|
|
return bankaccounts.value.find((account) => account.id === accountOrId) || null
|
|
}
|
|
const getBankAccountLabel = (account) => {
|
|
if (!account) return ""
|
|
if (isCashbookAccount(account)) return `${account.name || account.ownerName || "Kassenbuch"} - Konto ${account.datevNumber || account.iban}`
|
|
return `${account.name || account.ownerName || "Bankkonto"} - ${account.iban}`
|
|
}
|
|
const selectedAccountLabel = computed(() => {
|
|
if (filterAccount.value.length === 0) return "Keine Konten"
|
|
if (filterAccount.value.length === bankaccounts.value.length) return "Alle Konten"
|
|
if (filterAccount.value.length === 1) return getBankAccountLabel(resolveBankAccount(filterAccount.value[0]))
|
|
return `${filterAccount.value.length} Konten`
|
|
})
|
|
const selectedCashbookAccount = computed(() => {
|
|
if (filterAccount.value.length !== 1) return null
|
|
const account = resolveBankAccount(filterAccount.value[0])
|
|
return isCashbookAccount(account) ? account : null
|
|
})
|
|
const currentCashbookBalance = computed(() => {
|
|
if (!selectedCashbookAccount.value) return 0
|
|
const openingBalance = Number(selectedCashbookAccount.value.balance || 0)
|
|
const movementSum = bankstatements.value
|
|
.filter((statement) => statement.account === selectedCashbookAccount.value.id)
|
|
.reduce((sum, statement) => sum + Number(statement.amount || 0), 0)
|
|
return openingBalance + movementSum
|
|
})
|
|
const saveCashbookBooking = async () => {
|
|
if (!selectedCashbookAccount.value || !cashbookForm.date || !cashbookForm.amount) {
|
|
toast.add({ title: "Bitte Kasse, Datum und Betrag auswählen.", color: "warning" })
|
|
return
|
|
}
|
|
|
|
savingCashbookBooking.value = true
|
|
try {
|
|
const created = await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, {
|
|
method: "POST",
|
|
body: {
|
|
date: cashbookForm.date,
|
|
direction: cashbookForm.direction,
|
|
amount: Number(cashbookForm.amount)
|
|
}
|
|
})
|
|
toast.add({ title: "Kassenbuchung erstellt." })
|
|
cashbookForm.amount = null
|
|
cashbookBookingModalOpen.value = false
|
|
await router.push(`/banking/statements/edit/${created.statement.id}`)
|
|
} finally {
|
|
savingCashbookBooking.value = false
|
|
}
|
|
}
|
|
|
|
const normalizeIban = (value) => String(value || "").replace(/\s+/g, "").toUpperCase()
|
|
const normalizeSuggestionText = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9äöüß]+/gi, " ").replace(/\s+/g, " ").trim()
|
|
const getSuggestionTokens = (value) => [...new Set(normalizeSuggestionText(value).split(" ").filter((token) => token.length >= 4))]
|
|
const getMatchingPurposeParts = (value, candidates = []) => {
|
|
const haystack = normalizeSuggestionText(value)
|
|
if (!haystack) return []
|
|
|
|
return [...new Set(
|
|
candidates
|
|
.flatMap(candidate => getSuggestionTokens(candidate))
|
|
.filter(token => haystack.includes(token))
|
|
)].slice(0, 3)
|
|
}
|
|
|
|
const getAmountMatchLabel = (openSum, remaining) => {
|
|
const difference = Math.abs(Math.abs(Number(openSum)) - Math.abs(Number(remaining)))
|
|
if (difference < 0.01) return "Betrag exakt"
|
|
if (difference <= 1) return "Betrag fast passend"
|
|
if (difference <= 5) return "Betrag ähnlich"
|
|
return null
|
|
}
|
|
|
|
const isWithinAmountTolerance = (candidateSum, remaining) => {
|
|
const reference = Math.abs(Number(remaining))
|
|
const candidate = Math.abs(Number(candidateSum))
|
|
if (!reference || !candidate) return false
|
|
|
|
const difference = Math.abs(candidate - reference)
|
|
return difference / reference <= 0.1
|
|
}
|
|
|
|
const resolveMatchedBankAccountId = (statement) => {
|
|
const partnerIban = normalizeIban(statement.amount >= 0 ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban))
|
|
if (!partnerIban) return null
|
|
return entitybankaccounts.value.find(account => normalizeIban(account.iban) === partnerIban)?.id || null
|
|
}
|
|
|
|
const getTopEntitySuggestion = (statement) => {
|
|
const isCustomer = Number(statement.amount) >= 0
|
|
const partnerName = normalizeSuggestionText(isCustomer ? (statement.debName || statement.credName) : (statement.credName || statement.debName))
|
|
const partnerIban = normalizeIban(isCustomer ? (statement.debIban || statement.credIban) : (statement.credIban || statement.debIban))
|
|
const matchedBankAccountId = resolveMatchedBankAccountId(statement)
|
|
const sourceItems = isCustomer ? customers.value : vendors.value
|
|
|
|
const suggestions = sourceItems.map(item => {
|
|
const infoData = item.infoData || {}
|
|
const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : []
|
|
const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map(iban => normalizeIban(iban)) : []
|
|
const normalizedEntityName = normalizeSuggestionText(item.name)
|
|
const exactNameMatch = normalizedEntityName && partnerName && normalizedEntityName === partnerName
|
|
const partialNameMatch = normalizedEntityName && partnerName
|
|
? normalizedEntityName.includes(partnerName) || partnerName.includes(normalizedEntityName)
|
|
: false
|
|
|
|
let score = 0
|
|
let reason = ""
|
|
|
|
if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId) && partnerIban && bankingIbans.includes(partnerIban)) {
|
|
score = 100
|
|
reason = "IBAN und Bankkonto passen"
|
|
} else if (matchedBankAccountId && bankAccountIds.includes(matchedBankAccountId)) {
|
|
score = 95
|
|
reason = "Bankkonto passt"
|
|
} else if (partnerIban && bankingIbans.includes(partnerIban)) {
|
|
score = 90
|
|
reason = "IBAN bekannt"
|
|
} else if (exactNameMatch) {
|
|
score = 60
|
|
reason = "Name passt"
|
|
} else if (partialNameMatch) {
|
|
score = 45
|
|
reason = "Name ähnlich"
|
|
}
|
|
|
|
return {
|
|
type: isCustomer ? "customer" : "vendor",
|
|
id: item.id,
|
|
name: item.name,
|
|
number: isCustomer ? item.customerNumber : item.vendorNumber,
|
|
score,
|
|
reason
|
|
}
|
|
}).filter(item => item.score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
return suggestions[0] || null
|
|
}
|
|
|
|
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
|
|
let score = 0
|
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
|
const openSum = Math.abs(Number(document.openSum))
|
|
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
|
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
|
|
|
if (!withinTolerance) {
|
|
return { score: 0, amountLabel: null, purposeMatches: [] }
|
|
}
|
|
|
|
if (openSum === remaining) score += 100
|
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
|
|
|
if (suggestion && document.customer?.id === suggestion.id) score += 80
|
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
|
|
|
return { score, amountLabel, purposeMatches }
|
|
}
|
|
|
|
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
|
|
let score = 0
|
|
const remaining = Math.abs(Number(calculateOpenSum(statement)))
|
|
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
|
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
|
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
|
|
|
if (!withinTolerance) {
|
|
return { score: 0, amountLabel: null, purposeMatches: [] }
|
|
}
|
|
|
|
if (openSum === remaining) score += 100
|
|
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
|
|
|
|
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
|
|
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
|
|
|
|
return { score, amountLabel, purposeMatches }
|
|
}
|
|
|
|
const getRowSuggestions = (statement) => {
|
|
const entitySuggestion = getTopEntitySuggestion(statement)
|
|
|
|
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
|
|
.map(document => {
|
|
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
|
|
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` }
|
|
})
|
|
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
|
|
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null
|
|
|
|
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
|
|
.map(invoice => {
|
|
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
|
|
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` }
|
|
})
|
|
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
|
|
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)[0] || null
|
|
|
|
return {
|
|
topDocument,
|
|
topIncomingInvoice
|
|
}
|
|
}
|
|
|
|
const rowSuggestions = computed(() => {
|
|
const entries = filteredRows.value.map(row => [row.id, getRowSuggestions(row)])
|
|
return Object.fromEntries(entries)
|
|
})
|
|
|
|
const dismissedSuggestions = computed(() => tempStore.settings?.banking?.dismissedSuggestions || {})
|
|
|
|
const getDismissedSuggestionKeys = (statementId) => {
|
|
const key = String(statementId || "")
|
|
const values = dismissedSuggestions.value?.[key]
|
|
return Array.isArray(values) ? values : []
|
|
}
|
|
|
|
const isSuggestionDismissed = (statementId, suggestionKey) => {
|
|
return getDismissedSuggestionKeys(statementId).includes(suggestionKey)
|
|
}
|
|
|
|
const rowsWithSuggestions = computed(() => {
|
|
return filteredRows.value
|
|
.map((row) => ({
|
|
row,
|
|
suggestions: rowSuggestions.value[row.id]
|
|
}))
|
|
.filter(({ suggestions }) => suggestions?.topDocument || suggestions?.topIncomingInvoice)
|
|
})
|
|
|
|
const suggestionCount = computed(() => rowsWithSuggestions.value.length)
|
|
|
|
const selectedSuggestionRow = computed(() => {
|
|
return rowsWithSuggestions.value.find((entry) => entry.row.id === selectedSuggestionRowId.value) || rowsWithSuggestions.value[0] || null
|
|
})
|
|
|
|
const filteredRows = computed(() => {
|
|
if (!bankstatements.value.length) return []
|
|
|
|
let temp = [...bankstatements.value]
|
|
|
|
// Filterung nach Datum
|
|
if (dateRange.value.start) {
|
|
temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
|
|
}
|
|
if (dateRange.value.end) {
|
|
temp = temp.filter(i => $dayjs(getStatementDate(i)).isSameOrBefore($dayjs(dateRange.value.end), 'day'))
|
|
}
|
|
|
|
// Status Filter
|
|
if (selectedFilters.value.includes("Nur offene anzeigen")) {
|
|
temp = temp.filter(i => Number(calculateOpenSum(i)) !== 0)
|
|
}
|
|
if (selectedFilters.value.includes("Nur positive anzeigen")) {
|
|
temp = temp.filter(i => i.amount >= 0)
|
|
}
|
|
if (selectedFilters.value.includes("Nur negative anzeigen")) {
|
|
temp = temp.filter(i => i.amount < 0)
|
|
}
|
|
|
|
// Konto Filter & Suche
|
|
let results = temp.filter(i => filterAccount.value.find(x => getBankAccountId(x) === i.account))
|
|
|
|
if (searchString.value) {
|
|
results = useSearch(searchString.value, results)
|
|
}
|
|
|
|
return results.sort((a, b) => $dayjs(getStatementDate(b)).unix() - $dayjs(getStatementDate(a)).unix())
|
|
})
|
|
|
|
const displayCurrency = (value) => `${Number(value).toFixed(2).replace(".", ",")} €`
|
|
|
|
const saveAllocation = async (allocation) => {
|
|
await $api("/api/banking/statements", {
|
|
method: "POST",
|
|
body: { data: allocation }
|
|
})
|
|
await setupPage()
|
|
}
|
|
|
|
const handleAssignDocument = async (row, document, event) => {
|
|
event.stopPropagation()
|
|
await saveAllocation({
|
|
createddocument: document.id,
|
|
bankstatement: row.id,
|
|
amount: Number(Number(document.openSum) < Number(calculateOpenSum(row)) ? document.openSum : calculateOpenSum(row)),
|
|
description: "Automatischer Vorschlag"
|
|
})
|
|
}
|
|
|
|
const handleAssignIncomingInvoice = async (row, invoice, event) => {
|
|
event.stopPropagation()
|
|
await saveAllocation({
|
|
incominginvoice: invoice.id,
|
|
bankstatement: row.id,
|
|
amount: Number(Math.abs(getInvoiceSum(invoice, true)) > Math.abs(Number(calculateOpenSum(row))) ? calculateOpenSum(row) : getInvoiceSum(invoice, true)),
|
|
description: "Automatischer Vorschlag"
|
|
})
|
|
}
|
|
|
|
const dismissSuggestion = (row, suggestionKey, event) => {
|
|
event.stopPropagation()
|
|
const bankingSettings = tempStore.settings?.banking || {}
|
|
const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) }
|
|
const rowKey = String(row.id || "")
|
|
const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : []
|
|
if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey]
|
|
|
|
tempStore.modifySettings("banking", {
|
|
...bankingSettings,
|
|
dismissedSuggestions: dismissed
|
|
})
|
|
|
|
if (selectedSuggestionRowId.value === row.id) {
|
|
const remaining = getDismissedSuggestionKeys(row.id).concat([suggestionKey])
|
|
const current = rowSuggestions.value[row.id]
|
|
const noMoreSuggestions = (!current?.topDocument || remaining.includes(current.topDocument.suggestionKey))
|
|
&& (!current?.topIncomingInvoice || remaining.includes(current.topIncomingInvoice.suggestionKey))
|
|
|
|
if (noMoreSuggestions) {
|
|
const nextRow = rowsWithSuggestions.value.find((entry) => entry.row.id !== row.id)
|
|
selectedSuggestionRowId.value = nextRow?.row.id || null
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
setupPage()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar title="Bankbuchungen" :badge="filteredRows.length">
|
|
<template #right>
|
|
<UButton
|
|
v-if="selectedCashbookAccount"
|
|
color="primary"
|
|
variant="solid"
|
|
icon="i-heroicons-plus"
|
|
@click="cashbookBookingModalOpen = true"
|
|
>
|
|
Eintrag hinzufügen
|
|
</UButton>
|
|
<UButton
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-heroicons-sparkles"
|
|
:loading="loadingSuggestions"
|
|
:disabled="loadingSuggestions"
|
|
@click="suggestionsModalOpen = true"
|
|
>
|
|
Vorschläge
|
|
<UBadge color="primary" variant="solid" size="xs" class="ml-2">{{ suggestionCount }}</UBadge>
|
|
</UButton>
|
|
<UButton
|
|
label="Bankabruf"
|
|
icon="i-heroicons-arrow-path"
|
|
:loading="isSyncing"
|
|
@click="syncBankStatements"
|
|
class="mr-2"
|
|
/>
|
|
<UInput
|
|
id="searchinput"
|
|
v-model="searchString"
|
|
icon="i-heroicons-magnifying-glass"
|
|
placeholder="Suche..."
|
|
@change="tempStore.modifySearchString('bankstatements',searchString)"
|
|
/>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardToolbar>
|
|
<template #left>
|
|
<div class="flex items-center gap-3">
|
|
<USelectMenu
|
|
:items="bankaccounts"
|
|
v-model="filterAccount"
|
|
value-key="id"
|
|
label-key="displayLabel"
|
|
multiple
|
|
placeholder="Konten"
|
|
class="w-64"
|
|
>
|
|
<template #default>
|
|
{{ selectedAccountLabel }}
|
|
</template>
|
|
</USelectMenu>
|
|
<USeparator orientation="vertical" class="h-6"/>
|
|
<div class="flex items-center gap-2">
|
|
<USelectMenu
|
|
v-model="selectedPeriod"
|
|
:items="periodOptions"
|
|
value-key="key"
|
|
label-key="label"
|
|
class="w-44"
|
|
icon="i-heroicons-calendar-days"
|
|
>
|
|
<template #default>
|
|
{{ selectedPeriodOption.label || 'Zeitraum' }}
|
|
</template>
|
|
</USelectMenu>
|
|
<div v-if="selectedPeriod === 'custom'" class="flex items-center gap-1">
|
|
<div class="flex items-center gap-1">
|
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
|
<UButton
|
|
block
|
|
color="neutral"
|
|
variant="outline"
|
|
class="w-36 justify-start"
|
|
icon="i-heroicons-calendar"
|
|
:label="getDateButtonLabel(dateRange.start)"
|
|
/>
|
|
|
|
<template #content>
|
|
<div class="p-2">
|
|
<UCalendar
|
|
:model-value="getCalendarValue(dateRange.start)"
|
|
@update:model-value="setDateRangeFromCalendar('start', $event)"
|
|
:week-starts-on="1"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UPopover>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
|
<UButton
|
|
block
|
|
color="neutral"
|
|
variant="outline"
|
|
class="w-36 justify-start"
|
|
icon="i-heroicons-calendar"
|
|
:label="getDateButtonLabel(dateRange.end)"
|
|
/>
|
|
|
|
<template #content>
|
|
<div class="p-2">
|
|
<UCalendar
|
|
:model-value="getCalendarValue(dateRange.end)"
|
|
@update:model-value="setDateRangeFromCalendar('end', $event)"
|
|
:week-starts-on="1"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UPopover>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
|
{{ $dayjs(dateRange.start).format('DD.MM.') }} - {{ $dayjs(dateRange.end).format('DD.MM.YYYY') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #right>
|
|
<USelectMenu
|
|
icon="i-heroicons-adjustments-horizontal"
|
|
multiple
|
|
v-model="selectedFilters"
|
|
:items="bankingFilterItems"
|
|
value-key="value"
|
|
label-key="label"
|
|
@update:model-value="tempStore.modifyFilter('banking','main',selectedFilters)"
|
|
>
|
|
<template #default>
|
|
Filter
|
|
</template>
|
|
</USelectMenu>
|
|
</template>
|
|
</UDashboardToolbar>
|
|
|
|
<div class="overflow-y-auto relative" style="height: calc(100vh - 200px)">
|
|
<div v-if="loadingDocs" class="p-20 flex flex-col items-center justify-center">
|
|
<UProgress animation="carousel" class="w-1/3 mb-4" />
|
|
<span class="text-sm text-gray-500 italic">Bankbuchungen werden geladen...</span>
|
|
</div>
|
|
|
|
<table v-else class="w-full text-left border-collapse">
|
|
<thead class="sticky top-0 bg-white dark:bg-gray-900 z-10 shadow-sm">
|
|
<tr class="text-xs font-semibold text-gray-500 uppercase">
|
|
<th v-for="col in templateColumns" :key="col.key" class="p-4 border-b dark:border-gray-800">
|
|
{{ col.label }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="(row, index) in filteredRows" :key="row.id">
|
|
<tr v-if="shouldShowMonthDivider(row, index)">
|
|
<td colspan="6" class="bg-gray-50 dark:bg-gray-800/50 p-2 pl-4 text-sm font-bold text-primary-600 border-y dark:border-gray-800">
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-heroicons-calendar" class="w-4 h-4"/>
|
|
{{ $dayjs(getStatementDate(row)).format('MMMM YYYY') }}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 cursor-pointer border-b dark:border-gray-800 text-sm group"
|
|
@click="router.push(`/banking/statements/edit/${row.id}`)"
|
|
>
|
|
<td class="p-4 text-[10px] text-gray-400 font-mono truncate max-w-[150px]">
|
|
{{ row.account ? getBankAccountLabel(bankaccounts.find(i => i.id === row.account)) : "" }}
|
|
</td>
|
|
<td class="p-4 whitespace-nowrap">{{ $dayjs(getStatementDate(row)).format("DD.MM.YY") }}</td>
|
|
<td class="p-4 font-semibold">
|
|
<span :class="row.amount >= 0 ? 'text-green-600 dark:text-green-400' : 'text-rose-600 dark:text-rose-400'">
|
|
{{ displayCurrency(row.amount) }}
|
|
</span>
|
|
</td>
|
|
<td class="p-4 text-gray-400 italic text-xs">
|
|
{{ Number(calculateOpenSum(row)) !== 0 ? displayCurrency(calculateOpenSum(row)) : '-' }}
|
|
</td>
|
|
<td class="p-4 truncate max-w-[180px] font-medium">
|
|
{{ row.amount < 0 ? row.credName : row.debName }}
|
|
</td>
|
|
<td class="p-4 text-gray-500 max-w-[350px] text-xs">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<UTooltip v-if="String(row.notes || '').trim()" text="Notiz hinterlegt">
|
|
<UIcon
|
|
name="i-heroicons-chat-bubble-left-ellipsis"
|
|
class="w-4 h-4 shrink-0 text-primary-500"
|
|
/>
|
|
</UTooltip>
|
|
<span class="truncate">{{ row.text }}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<tr v-if="filteredRows.length === 0">
|
|
<td colspan="6" class="p-32 text-center text-gray-400">
|
|
<div class="flex flex-col items-center">
|
|
<UIcon name="i-heroicons-magnifying-glass-circle" class="w-12 h-12 mb-3 opacity-20"/>
|
|
<p class="font-medium">Keine Buchungen gefunden</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<PageLeaveGuard :when="isSyncing"/>
|
|
|
|
<UModal v-model:open="cashbookBookingModalOpen" :ui="{ width: 'sm:max-w-lg' }">
|
|
<template #content>
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div class="text-lg font-semibold">Kassenbucheintrag hinzufügen</div>
|
|
<div class="text-sm text-gray-500">{{ selectedCashbookAccount?.name || "Kassenbuch" }}</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UBadge color="neutral" variant="subtle">
|
|
Konto {{ selectedCashbookAccount?.datevNumber || selectedCashbookAccount?.iban }}
|
|
</UBadge>
|
|
<UBadge :color="currentCashbookBalance < 0 ? 'error' : 'success'" variant="subtle">
|
|
Bestand {{ displayCurrency(currentCashbookBalance) }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-4">
|
|
<div class="space-y-3">
|
|
<UFormField label="Buchungsdatum">
|
|
<UInput v-model="cashbookForm.date" type="date" />
|
|
</UFormField>
|
|
<UFormField label="Betrag">
|
|
<UInput v-model="cashbookForm.amount" type="number" min="0" step="0.01" placeholder="0,00" />
|
|
</UFormField>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<button
|
|
type="button"
|
|
class="w-full rounded-lg border px-4 py-3 text-left transition"
|
|
:class="cashbookForm.direction === 'income'
|
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
|
|
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
|
|
@click="cashbookForm.direction = 'income'"
|
|
>
|
|
<div class="font-semibold text-emerald-700 dark:text-emerald-300">Einnahme</div>
|
|
<div class="text-sm text-gray-500">Bargeld kommt in die Kasse.</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="w-full rounded-lg border px-4 py-3 text-left transition"
|
|
:class="cashbookForm.direction === 'expense'
|
|
? 'border-red-500 bg-red-50 dark:bg-red-950/30'
|
|
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
|
|
@click="cashbookForm.direction = 'expense'"
|
|
>
|
|
<div class="font-semibold text-red-700 dark:text-red-300">Ausgabe</div>
|
|
<div class="text-sm text-gray-500">Bargeld wird aus der Kasse entnommen.</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton color="neutral" variant="ghost" @click="cashbookBookingModalOpen = false">
|
|
Abbrechen
|
|
</UButton>
|
|
<UButton color="primary" icon="i-heroicons-check" :loading="savingCashbookBooking" @click="saveCashbookBooking">
|
|
Anlegen und bearbeiten
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal v-model:open="suggestionsModalOpen" :ui="{ width: 'sm:max-w-6xl' }">
|
|
<template #content>
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div class="text-lg font-semibold">Vorschläge für Bankbuchungen</div>
|
|
<div class="text-sm text-gray-500">Direkte Zuweisung von passenden Rechnungen und Eingangsbelegen</div>
|
|
</div>
|
|
<UBadge color="primary" variant="subtle">{{ suggestionCount }}</UBadge>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="rowsWithSuggestions.length > 0" class="grid grid-cols-1 lg:grid-cols-3 gap-4 min-h-[520px]">
|
|
<div class="lg:col-span-1 border rounded-lg overflow-hidden dark:border-gray-800">
|
|
<div
|
|
v-for="entry in rowsWithSuggestions"
|
|
:key="entry.row.id"
|
|
class="p-3 border-b dark:border-gray-800 cursor-pointer transition-colors"
|
|
:class="selectedSuggestionRowId === entry.row.id || (!selectedSuggestionRowId && selectedSuggestionRow?.row.id === entry.row.id) ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-900/50'"
|
|
@click="selectedSuggestionRowId = entry.row.id"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-medium truncate">{{ entry.row.amount < 0 ? entry.row.credName : entry.row.debName }}</div>
|
|
<div class="text-xs text-gray-500 truncate">{{ entry.row.text || 'Ohne Verwendungszweck' }}</div>
|
|
</div>
|
|
<div class="text-xs font-mono whitespace-nowrap" :class="entry.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">
|
|
{{ displayCurrency(entry.row.amount) }}
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 flex flex-wrap gap-1">
|
|
<UBadge v-if="entry.suggestions?.topDocument" size="xs" color="emerald" variant="subtle">Rechnung</UBadge>
|
|
<UBadge v-if="entry.suggestions?.topIncomingInvoice" size="xs" color="error" variant="subtle">Eingangsbeleg</UBadge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lg:col-span-2 space-y-3" v-if="selectedSuggestionRow">
|
|
<div class="rounded-lg border dark:border-gray-800 p-4 bg-gray-50 dark:bg-gray-900/50">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div class="text-sm font-semibold">{{ selectedSuggestionRow.row.amount < 0 ? selectedSuggestionRow.row.credName : selectedSuggestionRow.row.debName }}</div>
|
|
<div class="text-xs text-gray-500 mt-1">{{ selectedSuggestionRow.row.text || 'Ohne Verwendungszweck' }}</div>
|
|
<div class="text-xs text-gray-400 mt-1">{{ $dayjs(getStatementDate(selectedSuggestionRow.row)).format("DD.MM.YYYY") }}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="font-mono font-semibold" :class="selectedSuggestionRow.row.amount >= 0 ? 'text-green-600' : 'text-rose-600'">{{ displayCurrency(selectedSuggestionRow.row.amount) }}</div>
|
|
<div class="text-xs text-gray-400">Offen {{ displayCurrency(calculateOpenSum(selectedSuggestionRow.row)) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="selectedSuggestionRow.suggestions?.topDocument" class="rounded-lg border border-emerald-200 bg-emerald-50/70 px-4 py-3">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-emerald-700">Rechnung {{ selectedSuggestionRow.suggestions.topDocument.documentNumber }}</div>
|
|
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topDocument.customer?.name }} | Offen {{ displayCurrency(selectedSuggestionRow.suggestions.topDocument.openSum) }}</div>
|
|
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel || selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches?.length">
|
|
<UBadge v-if="selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
|
{{ selectedSuggestionRow.suggestions.topDocument.suggestionAmountLabel }}
|
|
</UBadge>
|
|
<UBadge v-for="match in selectedSuggestionRow.suggestions.topDocument.suggestionPurposeMatches" :key="`modal-doc-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
|
VWZ: {{ match }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
|
|
Zuweisen
|
|
</UButton>
|
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
|
|
Ablehnen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="selectedSuggestionRow.suggestions?.topIncomingInvoice" class="rounded-lg border border-rose-200 bg-rose-50/70 px-4 py-3">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-rose-700">Eingangsbeleg {{ selectedSuggestionRow.suggestions.topIncomingInvoice.reference || '-' }}</div>
|
|
<div class="text-xs text-gray-600 mt-1">{{ selectedSuggestionRow.suggestions.topIncomingInvoice.vendor?.name }} | Offen {{ displayCurrency(getInvoiceSum(selectedSuggestionRow.suggestions.topIncomingInvoice, true)) }}</div>
|
|
<div class="mt-2 flex flex-wrap gap-1" v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel || selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches?.length">
|
|
<UBadge v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
|
{{ selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionAmountLabel }}
|
|
</UBadge>
|
|
<UBadge v-for="match in selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionPurposeMatches" :key="`modal-invoice-${selectedSuggestionRow.row.id}-${match}`" size="xs" color="amber" variant="subtle">
|
|
VWZ: {{ match }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
|
|
Zuweisen
|
|
</UButton>
|
|
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">
|
|
Ablehnen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="py-10 text-center text-gray-400">
|
|
<UIcon name="i-heroicons-sparkles" class="w-10 h-10 mx-auto mb-2 opacity-30"/>
|
|
<p>Keine Vorschläge für die aktuelle Filterung vorhanden.</p>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
</template>
|