Files
FEDEO/frontend/pages/banking/index.vue
florianfederspiel 03bcc1a939
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 2m43s
2. Zwischenstand
2026-03-21 22:56:56 +01:00

713 lines
31 KiB
Vue

<script setup>
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 isSyncing = ref(false)
const loadingDocs = ref(true) // Startet im Ladezustand
const suggestionsModalOpen = ref(false)
const selectedSuggestionRowId = ref(null)
// 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'}
]
// Initialisierungswerte
const selectedPeriod = ref(periodOptions[0])
const dateRange = ref({
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
end: $dayjs().endOf('month').format('YYYY-MM-DD')
})
const setDateRangeFieldToToday = (field) => {
dateRange.value[field] = $dayjs().format('YYYY-MM-DD')
}
const setupPage = async () => {
loadingDocs.value = true
try {
const [statements, accounts, customerItems, vendorItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
useEntities("bankaccounts").select(),
useEntities("customers").select(),
useEntities("vendors").select(),
useEntities("entitybankaccounts").select(),
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")
])
bankstatements.value = statements
bankaccounts.value = accounts
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(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}))
openIncomingInvoices.value = invoiceItems
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
if (bankaccounts.value.length > 0 && filterAccount.value.length === 0) {
filterAccount.value = bankaccounts.value
}
// 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
}
if (savedBanking.range) {
dateRange.value = savedBanking.range
}
} catch (err) {
console.error("Setup Error:", err)
} finally {
loadingDocs.value = false
}
}
// Watcher für Schnellwahlen & Persistenz
watch([selectedPeriod, dateRange], ([newPeriod, newRange], [oldPeriod, oldRange]) => {
const now = $dayjs()
// Nur berechnen, wenn sich die Periode geändert hat
if (newPeriod.key !== oldPeriod?.key) {
switch (newPeriod.key) {
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
}
}
// Speichern im Store
tempStore.modifyBankingPeriod(selectedPeriod.value.key, dateRange.value)
}, { deep: true })
const syncBankStatements = async () => {
isSyncing.value = true
try {
await $api('/api/functions/services/bankstatementsync', {method: 'POST'})
toast.add({title: 'Erfolg', description: 'Bankdaten synchronisiert.', color: 'green'})
await setupPage()
} catch (error) {
toast.add({title: 'Fehler', description: 'Fehler beim Abruf.', 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(row.valueDate).format('MMMM YYYY') !== $dayjs(prevRow.valueDate).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 = 0
if (invoice.accounts) {
invoice.accounts.forEach(account => {
sum += (account.amountTax || 0)
sum += (account.amountNet || 0)
})
}
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (invoice.expense) {
return (sum * -1).toFixed(2)
} else {
return sum.toFixed(2)
}
}
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 aehnlich"
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 aehnlich"
}
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(i.valueDate).isSameOrAfter($dayjs(dateRange.value.start), 'day'))
}
if (dateRange.value.end) {
temp = temp.filter(i => $dayjs(i.valueDate).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 => x.id === i.account))
if (searchString.value) {
results = useSearch(searchString.value, results)
}
return results.sort((a, b) => $dayjs(b.valueDate).unix() - $dayjs(a.valueDate).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
color="primary"
variant="soft"
icon="i-heroicons-sparkles"
@click="suggestionsModalOpen = true"
>
Vorschlaege
<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
:options="bankaccounts"
v-model="filterAccount"
option-attribute="iban"
multiple
by="id"
placeholder="Konten"
class="w-48"
/>
<USeparator orientation="vertical" class="h-6"/>
<div class="flex items-center gap-2">
<USelectMenu
v-model="selectedPeriod"
:options="periodOptions"
class="w-44"
icon="i-heroicons-calendar-days"
/>
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
<div class="flex items-center gap-1">
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('start')" />
</div>
<div class="flex items-center gap-1">
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('end')" />
</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"
:options="['Nur offene anzeigen','Nur positive anzeigen','Nur negative anzeigen']"
@change="tempStore.modifyFilter('banking','main',selectedFilters)"
/>
</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(row.valueDate).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 ? bankaccounts.find(i => i.id === row.account)?.iban : "" }}
</td>
<td class="p-4 whitespace-nowrap">{{ $dayjs(row.valueDate).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 truncate max-w-[350px] text-xs">
{{ row.text }}
</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="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">Vorschlaege fuer 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(selectedSuggestionRow.row.valueDate).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 Vorschlaege fuer die aktuelle Filterung vorhanden.</p>
</div>
</UCard>
</template>
</UModal>
</template>