Files
FEDEO/frontend/pages/banking/index.vue
florianfederspiel af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
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>
2026-11-04 11:31:47 +01:00

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>