1123 lines
48 KiB
Vue
1123 lines
48 KiB
Vue
<script setup>
|
|
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 accounts = ref([])
|
|
const customers = ref([])
|
|
const vendors = ref([])
|
|
const ownaccounts = 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 suggestionsModalOpen = ref(false)
|
|
const selectedSuggestionRowId = ref(null)
|
|
const counterSearch = ref("")
|
|
const expandedGroups = ref([])
|
|
|
|
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,
|
|
counter: "",
|
|
datevTaxKey: "__none__",
|
|
description: ""
|
|
})
|
|
|
|
const DATEV_TAX_KEY_ITEMS = [
|
|
{ value: "__none__", label: "Ohne Steuerschlüssel" },
|
|
{ value: "9", label: "9 - Vorsteuer 19 %" },
|
|
{ value: "8", label: "8 - Vorsteuer 7 %" },
|
|
{ value: "19", label: "19 - EU Vorsteuer 19 %" },
|
|
{ value: "18", label: "18 - EU Vorsteuer 7 %" }
|
|
]
|
|
|
|
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 setupPage = async () => {
|
|
loadingDocs.value = true
|
|
try {
|
|
const [statements, bankAccountItems, accountItems, customerItems, vendorItems, ownAccountItems, entityBankItems, documentItems, invoiceItems] = await Promise.all([
|
|
useEntities("bankstatements").select("*, statementallocations(*)", "valueDate", false),
|
|
useEntities("bankaccounts").select(),
|
|
useEntities("accounts").selectSpecial("*", "number", true),
|
|
useEntities("customers").select(),
|
|
useEntities("vendors").select(),
|
|
useEntities("ownaccounts").select(),
|
|
useEntities("entitybankaccounts").select(),
|
|
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
|
|
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")
|
|
])
|
|
|
|
bankstatements.value = statements
|
|
bankaccounts.value = (bankAccountItems || []).map((account) => ({
|
|
...account,
|
|
displayLabel: getBankAccountLabel(account)
|
|
}))
|
|
accounts.value = accountItems
|
|
customers.value = customerItems
|
|
vendors.value = vendorItems
|
|
ownaccounts.value = ownAccountItems
|
|
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))
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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(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 = 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 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 getIncomingInvoiceGross = (invoice) => Number((invoice.accounts || []).reduce((sum, account) => {
|
|
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0)
|
|
}, 0))
|
|
const getIncomingInvoiceOpenAmount = (invoice) => {
|
|
const gross = getIncomingInvoiceGross(invoice)
|
|
const allocated = Number((invoice.statementallocations || []).reduce((sum, allocation) => sum + Number(allocation.amount || 0), 0))
|
|
return Math.abs(gross) - Math.abs(allocated)
|
|
}
|
|
const buildCashbookEntries = (rows, type, labelBuilder) =>
|
|
(rows || []).map((item) => ({
|
|
key: `${type}:${item.id}`,
|
|
id: item.id,
|
|
type,
|
|
number: item.number || item.vendorNumber || item.customerNumber || item.reference || "",
|
|
name: item.label || item.name || item.vendor?.name || "",
|
|
label: labelBuilder(item),
|
|
typeLabel:
|
|
type === "account"
|
|
? "Sachkonten"
|
|
: type === "vendor"
|
|
? "Kreditoren"
|
|
: type === "customer"
|
|
? "Debitoren"
|
|
: type === "incominginvoice"
|
|
? "Eingangsbelege"
|
|
: "Zusätzliche Konten"
|
|
}))
|
|
const cashbookEntryGroups = computed(() => ([
|
|
{
|
|
key: "account",
|
|
label: "Sachkonten",
|
|
entries: buildCashbookEntries(accounts.value, "account", (item) => `${item.number} - ${item.label}`)
|
|
},
|
|
{
|
|
key: "vendor",
|
|
label: "Kreditoren",
|
|
entries: buildCashbookEntries(vendors.value, "vendor", (item) => `${item.vendorNumber || "ohne Nr."} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "customer",
|
|
label: "Debitoren",
|
|
entries: buildCashbookEntries(customers.value, "customer", (item) => `${item.customerNumber || "ohne Nr."} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "ownaccount",
|
|
label: "Zusätzliche Konten",
|
|
entries: buildCashbookEntries(ownaccounts.value, "ownaccount", (item) => `${item.number} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "incominginvoice",
|
|
label: "Eingangsbelege",
|
|
entries: buildCashbookEntries(
|
|
incominginvoices.value.filter((invoice) => !invoice.archived && getIncomingInvoiceOpenAmount(invoice) > 0.004),
|
|
"incominginvoice",
|
|
(item) => `${item.reference || "Ohne Referenz"} - ${item.vendor?.name || "Ohne Lieferant"} - Offen ${displayCurrency(getIncomingInvoiceOpenAmount(item))}`
|
|
)
|
|
}
|
|
]))
|
|
const groupedCashbookCounterEntries = computed(() =>
|
|
cashbookEntryGroups.value.map((group) => ({
|
|
...group,
|
|
entries: group.entries.filter((entry) => {
|
|
const search = normalizeSuggestionText(counterSearch.value)
|
|
if (!search) return true
|
|
return [entry.number, entry.name, entry.label, entry.typeLabel]
|
|
.some((value) => normalizeSuggestionText(value).includes(search))
|
|
})
|
|
})).filter((group) => group.entries.length > 0)
|
|
)
|
|
const selectedCashbookCounter = computed(() => cashbookEntryGroups.value.flatMap((group) => group.entries).find((entry) => entry.key === cashbookForm.counter))
|
|
const isCashbookGroupExpanded = (groupKey) => expandedGroups.value.includes(groupKey)
|
|
const toggleCashbookGroupExpanded = (groupKey) => {
|
|
if (expandedGroups.value.includes(groupKey)) {
|
|
expandedGroups.value = expandedGroups.value.filter((item) => item !== groupKey)
|
|
return
|
|
}
|
|
expandedGroups.value = [...expandedGroups.value, groupKey]
|
|
}
|
|
const visibleCashbookEntries = (group) => {
|
|
if (group.entries.length <= 5 || isCashbookGroupExpanded(group.key)) return group.entries
|
|
return group.entries.slice(0, 5)
|
|
}
|
|
const saveCashbookBooking = async () => {
|
|
if (!selectedCashbookAccount.value || !cashbookForm.date || !cashbookForm.amount || !cashbookForm.counter) {
|
|
toast.add({ title: "Bitte Kasse, Datum, Betrag und Gegenkonto auswählen.", color: "warning" })
|
|
return
|
|
}
|
|
|
|
const [counterType, counterId] = String(cashbookForm.counter).split(":")
|
|
savingCashbookBooking.value = true
|
|
try {
|
|
await $api(`/api/banking/cashbooks/${selectedCashbookAccount.value.id}/bookings`, {
|
|
method: "POST",
|
|
body: {
|
|
date: cashbookForm.date,
|
|
direction: cashbookForm.direction,
|
|
amount: Number(cashbookForm.amount),
|
|
counterType,
|
|
counterId,
|
|
datevTaxKey: cashbookForm.datevTaxKey === "__none__" ? null : cashbookForm.datevTaxKey,
|
|
description: cashbookForm.description
|
|
}
|
|
})
|
|
toast.add({ title: "Kassenbuchung erstellt." })
|
|
cashbookForm.amount = null
|
|
cashbookForm.counter = ""
|
|
cashbookForm.datevTaxKey = "__none__"
|
|
cashbookForm.description = ""
|
|
counterSearch.value = ""
|
|
expandedGroups.value = []
|
|
cashbookBookingModalOpen.value = false
|
|
await setupPage()
|
|
} 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"
|
|
@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 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="cashbookBookingModalOpen" :ui="{ width: 'sm:max-w-5xl' }">
|
|
<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-6">
|
|
<div class="grid gap-3 sm:grid-cols-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>
|
|
<UFormField label="DATEV-Steuerschlüssel">
|
|
<USelect
|
|
v-model="cashbookForm.datevTaxKey"
|
|
:items="DATEV_TAX_KEY_ITEMS"
|
|
value-key="value"
|
|
label-key="label"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-[220px_minmax(0,1fr)]">
|
|
<div class="space-y-3">
|
|
<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 class="space-y-4">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 class="font-semibold text-gray-900 dark:text-white">Gegenkonto</h3>
|
|
<p class="text-sm text-gray-500">Sachkonto, Kreditor, Debitor, Eingangsbeleg oder zusätzliches Konto.</p>
|
|
</div>
|
|
<UInput v-model="counterSearch" icon="i-heroicons-magnifying-glass" placeholder="Gegenkonto durchsuchen..." class="max-w-sm" />
|
|
</div>
|
|
|
|
<div class="max-h-[360px] space-y-4 overflow-y-auto pr-1">
|
|
<div v-for="group in groupedCashbookCounterEntries" :key="group.key" class="space-y-2">
|
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">{{ group.label }}</div>
|
|
<div class="grid gap-2 md:grid-cols-2">
|
|
<button
|
|
v-for="entry in visibleCashbookEntries(group)"
|
|
:key="entry.key"
|
|
type="button"
|
|
class="rounded-lg border px-3 py-2 text-left transition"
|
|
:class="cashbookForm.counter === entry.key
|
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-950/30'
|
|
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-800 dark:bg-gray-900'"
|
|
@click="cashbookForm.counter = entry.key"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<span class="font-mono text-sm">{{ entry.number }}</span>
|
|
<span class="truncate text-sm">{{ entry.name }}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<UButton
|
|
v-if="group.entries.length > 5"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:label="isCashbookGroupExpanded(group.key) ? 'Weniger anzeigen' : `${group.entries.length - 5} weitere anzeigen`"
|
|
@click="toggleCashbookGroupExpanded(group.key)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="selectedCashbookCounter"
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-heroicons-arrows-right-left"
|
|
:title="cashbookForm.direction === 'income' ? `${selectedCashbookAccount?.datevNumber} an ${selectedCashbookCounter.number}` : `${selectedCashbookCounter.number} an ${selectedCashbookAccount?.datevNumber}`"
|
|
:description="`${selectedCashbookCounter.typeLabel.slice(0, -1)} ${selectedCashbookCounter.name} wird als Gegenkonto verwendet.`"
|
|
/>
|
|
|
|
<UFormField label="Beschreibung">
|
|
<UTextarea v-model="cashbookForm.description" placeholder="z. B. Bareinkauf Büromaterial" autoresize class="w-full" />
|
|
</UFormField>
|
|
</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">
|
|
Eintrag speichern
|
|
</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>
|