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>
1388 lines
61 KiB
Vue
1388 lines
61 KiB
Vue
<script setup>
|
|
import dayjs from "dayjs";
|
|
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
|
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
|
import {
|
|
DEPRECIATION_METHOD_ITEMS,
|
|
EXPENSE_BOOKING_MODE_ITEMS,
|
|
isDepreciationBookingMode,
|
|
normalizeExpenseBookingMode
|
|
} from "~/composables/useDepreciation"
|
|
// import {filter} from "vuedraggable/dist/vuedraggable.common.js"; // Scheint nicht genutzt zu werden, auskommentiert
|
|
|
|
defineShortcuts({
|
|
'backspace': () => {
|
|
router.push("/banking")
|
|
}
|
|
})
|
|
|
|
const dataStore = useDataStore()
|
|
const tempStore = useTempStore()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const mode = ref(route.params.mode || "show")
|
|
|
|
const itemInfo = ref({statementallocations: []})
|
|
const oldItemInfo = ref({})
|
|
|
|
const openDocuments = ref([])
|
|
const allocatedDocuments = ref([])
|
|
const openIncomingInvoices = ref([])
|
|
const allocatedIncomingInvoices = ref([])
|
|
const statementSuggestions = ref({ suggestions: [] })
|
|
|
|
const customers = ref([])
|
|
const vendors = ref([])
|
|
|
|
const createddocuments = ref([])
|
|
const incominginvoices = ref([])
|
|
const accounts = ref([])
|
|
const ownaccounts = ref([])
|
|
const costcentres = ref([])
|
|
|
|
const loading = ref(true)
|
|
const loadingDocuments = ref(true)
|
|
const savingNotes = ref(false)
|
|
const notesSaveDelay = 800
|
|
let notesSaveTimer = null
|
|
let notesSaveQueued = false
|
|
|
|
const rebuildDocumentLists = () => {
|
|
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
|
|
|
openDocuments.value = documents.filter(i => useSum().isOpenCreatedDocument(i, createddocuments.value))
|
|
openDocuments.value = openDocuments.value.map(i => {
|
|
return {
|
|
...i,
|
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
|
}
|
|
})
|
|
|
|
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
|
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
|
openIncomingInvoices.value = incominginvoices.value.filter(i =>
|
|
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
|
|
)
|
|
}
|
|
|
|
const setup = async () => {
|
|
loading.value = true
|
|
loadingDocuments.value = true
|
|
|
|
const supportingDataPromise = Promise.all([
|
|
useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"),
|
|
useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)"),
|
|
useEntities("ownaccounts").select(),
|
|
useEntities("customers").select(),
|
|
useEntities("vendors").select()
|
|
])
|
|
|
|
const [statement, accountItems, costCentreItems] = await Promise.all([
|
|
route.params.id
|
|
? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))")
|
|
: Promise.resolve(itemInfo.value),
|
|
useEntities("accounts").selectSpecial("*", "number", true),
|
|
useEntities("costcentres").select()
|
|
])
|
|
|
|
itemInfo.value = statement
|
|
accounts.value = accountItems
|
|
costcentres.value = costCentreItems
|
|
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
|
|
|
|
manualAllocationSum.value = calculateOpenSum.value
|
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
|
loading.value = false
|
|
|
|
const suggestionsPromise = itemInfo.value?.id
|
|
? useFunctions().useBankingStatementSuggestions(itemInfo.value.id)
|
|
: Promise.resolve({ suggestions: [] })
|
|
|
|
try {
|
|
const [documentItems, invoiceItems, ownAccountItems, customerItems, vendorItems] = await supportingDataPromise
|
|
|
|
createddocuments.value = documentItems
|
|
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
|
|
ownaccounts.value = ownAccountItems
|
|
customers.value = customerItems
|
|
vendors.value = vendorItems
|
|
rebuildDocumentLists()
|
|
} catch (error) {
|
|
console.error("Offene Belege konnten nicht geladen werden:", error)
|
|
} finally {
|
|
loadingDocuments.value = false
|
|
}
|
|
|
|
try {
|
|
statementSuggestions.value = await suggestionsPromise
|
|
} catch (error) {
|
|
console.error("Buchungsvorschläge konnten nicht geladen werden:", error)
|
|
statementSuggestions.value = { suggestions: [] }
|
|
}
|
|
}
|
|
|
|
const displayCurrency = (value, currency = "€") => {
|
|
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
|
|
}
|
|
|
|
// Angepasst: Prüft nun auch auf null/undefined, nicht nur auf leeren String
|
|
const separateIBAN = (input) => {
|
|
if (!input) return ""
|
|
const separates = input.toString().match(/.{1,4}/g)
|
|
return separates ? separates.join(" ") : input
|
|
}
|
|
|
|
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 calculateOpenSum = computed(() => {
|
|
let startingAmount = 0
|
|
if (itemInfo.value.statementallocations) {
|
|
itemInfo.value.statementallocations.forEach(item => {
|
|
startingAmount += item.amount
|
|
})
|
|
}
|
|
return (itemInfo.value.amount - startingAmount).toFixed(2)
|
|
})
|
|
|
|
const showAccountSelection = ref(false)
|
|
const accountToSave = ref("")
|
|
const ownAccountToSave = ref("")
|
|
const customerAccountToSave = ref("")
|
|
const vendorAccountToSave = ref("")
|
|
|
|
const selectAccount = (id) => {
|
|
accountToSave.value = id
|
|
showAccountSelection.value = false
|
|
}
|
|
|
|
const manualAllocationSum = ref(itemInfo.value.amount || 0)
|
|
const allocationDescription = ref("")
|
|
const allocationCostcentre = ref(null)
|
|
const allocationTaxKey = ref("40")
|
|
const allocationAmounts = computed(() => getBankAllocationAmounts({
|
|
bankstatement: itemInfo.value.id,
|
|
account: accountToSave.value,
|
|
ownaccount: ownAccountToSave.value,
|
|
amount: manualAllocationSum.value,
|
|
datevTaxKey: allocationTaxKey.value,
|
|
}))
|
|
const getCostCentreLabel = (id) => {
|
|
const centre = costcentres.value.find(item => item.id === id)
|
|
return centre ? `${centre.number} - ${centre.name}` : id
|
|
}
|
|
const getAllocationTaxLabel = (item) => BANK_ALLOCATION_TAX_ITEMS.find(tax => tax.value === item.datevTaxKey)?.label
|
|
|| (item.datevTaxKey ? `Steuerschlüssel ${item.datevTaxKey}` : "")
|
|
const allocationBookingMode = ref("expense")
|
|
const allocationDepreciationMonths = ref(36)
|
|
const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD"))
|
|
const allocationDepreciationMethod = ref("linear")
|
|
const allocationDepreciationLabel = ref("")
|
|
const allocationDepreciationGroup = ref("")
|
|
const allocationResidualValue = ref(0)
|
|
const showMoreWithoutRecipe = ref(false)
|
|
const showMoreText = ref(false)
|
|
|
|
const isDepreciationAllocation = computed(() => isDepreciationBookingMode(allocationBookingMode.value))
|
|
|
|
const buildAllocationPayload = (payload) => {
|
|
const base = {
|
|
...payload,
|
|
...((payload.account || payload.ownaccount) ? {
|
|
costcentre: allocationCostcentre.value || null,
|
|
datevTaxKey: allocationTaxKey.value || null,
|
|
} : {}),
|
|
bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value)
|
|
}
|
|
|
|
if (!isDepreciationAllocation.value) {
|
|
return {
|
|
...base,
|
|
depreciationMonths: null,
|
|
depreciationStartDate: null,
|
|
depreciationLabel: null,
|
|
depreciationGroup: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
depreciationMonths: Number(allocationDepreciationMonths.value || 0),
|
|
depreciationStartDate: allocationDepreciationStartDate.value || itemInfo.value?.date || null,
|
|
depreciationMethod: allocationDepreciationMethod.value,
|
|
depreciationLabel: allocationDepreciationLabel.value || allocationDescription.value || "Direkte Abschreibung",
|
|
depreciationGroup: allocationBookingMode.value === "depreciation_bundle" ? allocationDepreciationGroup.value || null : null,
|
|
residualValue: Number(allocationResidualValue.value || 0)
|
|
}
|
|
}
|
|
|
|
watch(allocationBookingMode, (value) => {
|
|
if (!isDepreciationBookingMode(value)) return
|
|
if (!allocationDepreciationStartDate.value) {
|
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
|
}
|
|
})
|
|
|
|
const saveAllocation = async (allocation) => {
|
|
const res = await useNuxtApp().$api("/api/banking/statements", {
|
|
method: "POST",
|
|
body: {data: buildAllocationPayload(allocation)}
|
|
})
|
|
|
|
if (res) {
|
|
itemInfo.value.statementallocations.push(res)
|
|
if (res.createddocument) {
|
|
const document = createddocuments.value.find(item => item.id === res.createddocument)
|
|
document?.statementallocations.push(res)
|
|
}
|
|
if (res.incominginvoice) {
|
|
const invoice = incominginvoices.value.find(item => item.id === res.incominginvoice)
|
|
invoice?.statementallocations.push(res)
|
|
}
|
|
rebuildDocumentLists()
|
|
manualAllocationSum.value = calculateOpenSum.value
|
|
accountToSave.value = null
|
|
vendorAccountToSave.value = null
|
|
customerAccountToSave.value = null
|
|
ownAccountToSave.value = null
|
|
allocationCostcentre.value = null
|
|
allocationTaxKey.value = "40"
|
|
allocationBookingMode.value = "expense"
|
|
allocationDepreciationMonths.value = 36
|
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
|
allocationDepreciationMethod.value = "linear"
|
|
allocationDepreciationLabel.value = ""
|
|
allocationDepreciationGroup.value = ""
|
|
allocationResidualValue.value = 0
|
|
// allocationDescription.value = null // Optional: Beschreibung behalten für nächste Buchung?
|
|
}
|
|
}
|
|
|
|
const removeAllocation = async (allocationId) => {
|
|
await useNuxtApp().$api(`/api/banking/statements/${allocationId}`, {
|
|
method: "DELETE"
|
|
})
|
|
itemInfo.value.statementallocations = itemInfo.value.statementallocations.filter(item => item.id !== allocationId)
|
|
createddocuments.value.forEach(document => {
|
|
document.statementallocations = document.statementallocations.filter(item => item.id !== allocationId)
|
|
})
|
|
incominginvoices.value.forEach(invoice => {
|
|
invoice.statementallocations = invoice.statementallocations.filter(item => item.id !== allocationId)
|
|
})
|
|
rebuildDocumentLists()
|
|
manualAllocationSum.value = calculateOpenSum.value
|
|
}
|
|
|
|
const saveNotes = async () => {
|
|
if (!itemInfo.value?.id || !notesChanged.value) return
|
|
if (savingNotes.value) {
|
|
notesSaveQueued = true
|
|
return
|
|
}
|
|
|
|
const notes = String(itemInfo.value.notes || "").trim() || null
|
|
savingNotes.value = true
|
|
try {
|
|
const updated = await useEntities("bankstatements").update(itemInfo.value.id, {notes}, true)
|
|
oldItemInfo.value.notes = updated.notes
|
|
if (String(itemInfo.value.notes || "").trim() === String(notes || "")) {
|
|
itemInfo.value.notes = updated.notes
|
|
}
|
|
} finally {
|
|
savingNotes.value = false
|
|
if (notesSaveQueued || notesChanged.value) {
|
|
notesSaveQueued = false
|
|
scheduleNotesSave()
|
|
}
|
|
}
|
|
}
|
|
|
|
const notesChanged = computed(() =>
|
|
String(itemInfo.value?.notes || "").trim() !== String(oldItemInfo.value?.notes || "").trim()
|
|
)
|
|
|
|
const scheduleNotesSave = () => {
|
|
if (notesSaveTimer) clearTimeout(notesSaveTimer)
|
|
notesSaveTimer = setTimeout(() => {
|
|
notesSaveTimer = null
|
|
void saveNotes()
|
|
}, notesSaveDelay)
|
|
}
|
|
|
|
watch(() => itemInfo.value?.notes, () => {
|
|
if (!loading.value && notesChanged.value) scheduleNotesSave()
|
|
})
|
|
|
|
onBeforeRouteLeave(async () => {
|
|
if (notesSaveTimer) {
|
|
clearTimeout(notesSaveTimer)
|
|
notesSaveTimer = null
|
|
}
|
|
if (notesChanged.value) await saveNotes()
|
|
})
|
|
|
|
const searchString = ref(tempStore.searchStrings["bankstatementsedit"] || '')
|
|
|
|
const clearSearchString = () => {
|
|
searchString.value = ''
|
|
tempStore.clearSearchString("bankstatementsedit")
|
|
}
|
|
|
|
const filteredDocuments = computed(() => {
|
|
return useSearch(searchString.value, openDocuments.value.filter(i => i.state === "Gebucht"))
|
|
})
|
|
|
|
const filteredIncomingInvoices = computed(() => {
|
|
return useSearch(searchString.value, openIncomingInvoices.value.filter(i => i.state === "Gebucht"))
|
|
})
|
|
|
|
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 dismissSuggestion = (statementId, suggestionKey) => {
|
|
const bankingSettings = tempStore.settings?.banking || {}
|
|
const dismissed = { ...(bankingSettings.dismissedSuggestions || {}) }
|
|
const rowKey = String(statementId || "")
|
|
const existing = Array.isArray(dismissed[rowKey]) ? dismissed[rowKey] : []
|
|
if (!existing.includes(suggestionKey)) dismissed[rowKey] = [...existing, suggestionKey]
|
|
|
|
tempStore.modifySettings("banking", {
|
|
...bankingSettings,
|
|
dismissedSuggestions: dismissed
|
|
})
|
|
}
|
|
|
|
const topEntitySuggestion = computed(() => {
|
|
const suggestions = Array.isArray(statementSuggestions.value?.suggestions) ? statementSuggestions.value.suggestions : []
|
|
return suggestions[0] || null
|
|
})
|
|
|
|
const normalizeSuggestionText = (value) => {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9äöüß]+/gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
}
|
|
|
|
const getSuggestionTokens = (value) => {
|
|
return [...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 passend"
|
|
if (difference <= 1) return `Betrag fast passend (${displayCurrency(difference)} Abweichung)`
|
|
if (difference <= 5) return `Betrag aehnlich (${displayCurrency(difference)} Abweichung)`
|
|
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 getDocumentSuggestionMeta = (document, suggestion) => {
|
|
let score = 0
|
|
const remaining = Math.abs(Number(calculateOpenSum.value))
|
|
const openSum = Math.abs(Number(document.openSum))
|
|
const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [
|
|
document.documentNumber,
|
|
document.title,
|
|
document.description
|
|
])
|
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
|
|
|
if (!withinTolerance) {
|
|
return {
|
|
score: 0,
|
|
purposeMatches: [],
|
|
amountLabel: null
|
|
}
|
|
}
|
|
|
|
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,
|
|
purposeMatches,
|
|
amountLabel
|
|
}
|
|
}
|
|
|
|
const getInvoiceSuggestionMeta = (invoice, suggestion) => {
|
|
let score = 0
|
|
const remaining = Math.abs(Number(calculateOpenSum.value))
|
|
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
|
|
const purposeMatches = getMatchingPurposeParts(itemInfo.value.text, [
|
|
invoice.reference,
|
|
invoice.description
|
|
])
|
|
const amountLabel = getAmountMatchLabel(openSum, remaining)
|
|
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
|
|
|
|
if (!withinTolerance) {
|
|
return {
|
|
score: 0,
|
|
purposeMatches: [],
|
|
amountLabel: null
|
|
}
|
|
}
|
|
|
|
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,
|
|
purposeMatches,
|
|
amountLabel
|
|
}
|
|
}
|
|
|
|
const suggestedDocuments = computed(() => {
|
|
if (topEntitySuggestion.value?.type !== "customer") return []
|
|
|
|
return openDocuments.value
|
|
.filter((document) => document.customer?.id === topEntitySuggestion.value.id)
|
|
.map((document) => {
|
|
const meta = getDocumentSuggestionMeta(document, topEntitySuggestion.value)
|
|
return {
|
|
...document,
|
|
suggestionScore: meta.score,
|
|
suggestionPurposeMatches: meta.purposeMatches,
|
|
suggestionAmountLabel: meta.amountLabel,
|
|
suggestionKey: `document:${document.id}`
|
|
}
|
|
})
|
|
.filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey))
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
|
.slice(0, 3)
|
|
})
|
|
|
|
const suggestedIncomingInvoices = computed(() => {
|
|
if (topEntitySuggestion.value?.type !== "vendor") return []
|
|
|
|
return openIncomingInvoices.value
|
|
.filter((invoice) => invoice.vendor?.id === topEntitySuggestion.value.id)
|
|
.map((invoice) => {
|
|
const meta = getInvoiceSuggestionMeta(invoice, topEntitySuggestion.value)
|
|
return {
|
|
...invoice,
|
|
suggestionScore: meta.score,
|
|
suggestionPurposeMatches: meta.purposeMatches,
|
|
suggestionAmountLabel: meta.amountLabel,
|
|
suggestionKey: `invoice:${invoice.id}`
|
|
}
|
|
})
|
|
.filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey))
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
|
.slice(0, 3)
|
|
})
|
|
|
|
const fallbackSuggestedDocuments = computed(() => {
|
|
if (topEntitySuggestion.value) return []
|
|
|
|
return openDocuments.value
|
|
.map((document) => {
|
|
const meta = getDocumentSuggestionMeta(document, null)
|
|
return {
|
|
...document,
|
|
suggestionScore: meta.score,
|
|
suggestionPurposeMatches: meta.purposeMatches,
|
|
suggestionAmountLabel: meta.amountLabel,
|
|
suggestionKey: `document:${document.id}`
|
|
}
|
|
})
|
|
.filter((document) => !isSuggestionDismissed(itemInfo.value?.id, document.suggestionKey))
|
|
.filter((document) => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
|
.slice(0, 3)
|
|
})
|
|
|
|
const fallbackSuggestedIncomingInvoices = computed(() => {
|
|
if (topEntitySuggestion.value) return []
|
|
|
|
return openIncomingInvoices.value
|
|
.map((invoice) => {
|
|
const meta = getInvoiceSuggestionMeta(invoice, null)
|
|
return {
|
|
...invoice,
|
|
suggestionScore: meta.score,
|
|
suggestionPurposeMatches: meta.purposeMatches,
|
|
suggestionAmountLabel: meta.amountLabel,
|
|
suggestionKey: `invoice:${invoice.id}`
|
|
}
|
|
})
|
|
.filter((invoice) => !isSuggestionDismissed(itemInfo.value?.id, invoice.suggestionKey))
|
|
.filter((invoice) => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
|
|
.sort((a, b) => b.suggestionScore - a.suggestionScore)
|
|
.slice(0, 3)
|
|
})
|
|
|
|
const archiveStatement = async () => {
|
|
let temp = {...itemInfo.value}
|
|
delete temp.statementallocations
|
|
await useEntities("bankstatements").archive(temp.id)
|
|
}
|
|
|
|
// Helpers for the allocation list display
|
|
const getAllocationLabel = (item) => {
|
|
if (item.account) return `${accounts.value.find(i => i.id === item.account)?.number} - ${accounts.value.find(i => i.id === item.account)?.label}`
|
|
if (item.ownaccount) return `${ownaccounts.value.find(i => i.id === item.ownaccount)?.number} - ${ownaccounts.value.find(i => i.id === item.ownaccount)?.name}`
|
|
if (item.customer) return `${customers.value.find(i => i.id === item.customer)?.customerNumber} - ${customers.value.find(i => i.id === item.customer)?.name}`
|
|
if (item.vendor) return `${vendors.value.find(i => i.id === item.vendor)?.vendorNumber} - ${vendors.value.find(i => i.id === item.vendor)?.name}`
|
|
|
|
if (item.incominginvoice) {
|
|
const inv = incominginvoices.value.find(i => i.id === item.incominginvoice)
|
|
return `Eingangsrechnung: ${inv?.reference} (${inv?.vendor?.name})`
|
|
}
|
|
if (item.createddocument) {
|
|
const doc = createddocuments.value.find(i => i.id === item.createddocument)
|
|
return `${doc?.documentNumber} (${doc?.customer?.name})`
|
|
}
|
|
return "Unbekannte Zuordnung"
|
|
}
|
|
|
|
const getAllocationIcon = (item) => {
|
|
if (item.account || item.ownaccount) return 'i-heroicons-book-open'
|
|
if (item.customer || item.createddocument) return 'i-heroicons-arrow-up-right' // Einnahme / Kunde
|
|
if (item.vendor || item.incominginvoice) return 'i-heroicons-arrow-down-left' // Ausgabe / Lieferant
|
|
return 'i-heroicons-question-mark-circle'
|
|
}
|
|
|
|
const getAllocationBookingModeLabel = (item) => {
|
|
const mode = normalizeExpenseBookingMode(item?.bookingMode)
|
|
if (mode === "depreciation_bundle") return "Sammelabschreibung"
|
|
if (mode === "depreciation_single") return "Einzelabschreibung"
|
|
return "Sofortaufwand"
|
|
}
|
|
|
|
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
|
|
const cashDiscountModalOpen = ref(false)
|
|
const cashDiscountTarget = ref(null)
|
|
const cashDiscountInput = ref(0)
|
|
const savingCashDiscount = ref(false)
|
|
const cashDiscountError = ref("")
|
|
|
|
const getCashDiscountCandidate = (kind, entity) => {
|
|
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
|
|
const payment = Number(manualAllocationSum.value)
|
|
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
|
|
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
|
|
return { open, payment, discount: Number((open - payment).toFixed(2)) }
|
|
}
|
|
|
|
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
|
|
|
|
const openCashDiscountModal = (kind, entity) => {
|
|
const candidate = getCashDiscountCandidate(kind, entity)
|
|
if (!candidate) return
|
|
cashDiscountTarget.value = {
|
|
kind,
|
|
id: entity.id,
|
|
label: kind === "createddocument"
|
|
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
|
|
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
|
|
...candidate
|
|
}
|
|
cashDiscountInput.value = Math.abs(candidate.discount)
|
|
cashDiscountError.value = ""
|
|
cashDiscountModalOpen.value = true
|
|
}
|
|
|
|
const cashDiscountPercent = computed(() => {
|
|
const target = cashDiscountTarget.value
|
|
if (!target || !target.open) return 0
|
|
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
|
|
})
|
|
|
|
const cashDiscountRemaining = computed(() => {
|
|
const target = cashDiscountTarget.value
|
|
if (!target) return 0
|
|
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
|
|
})
|
|
|
|
const confirmCashDiscount = async () => {
|
|
const target = cashDiscountTarget.value
|
|
if (!target || savingCashDiscount.value) return
|
|
savingCashDiscount.value = true
|
|
try {
|
|
await saveAllocation({
|
|
[target.kind]: target.id,
|
|
bankstatement: itemInfo.value.id,
|
|
amount: target.payment,
|
|
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
|
|
description: allocationDescription.value || "Zahlung mit Skonto"
|
|
})
|
|
cashDiscountModalOpen.value = false
|
|
cashDiscountTarget.value = null
|
|
} catch (err) {
|
|
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
|
|
} finally {
|
|
savingCashDiscount.value = false
|
|
}
|
|
}
|
|
|
|
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
|
|
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
|
|
.join(" · ")
|
|
|
|
setup()
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar :ui="{center: 'flex items-stretch gap-1.5 min-w-0'}">
|
|
<template #left>
|
|
<UButton icon="i-heroicons-chevron-left" variant="outline" @click="router.back()">Zurück</UButton>
|
|
<UButton icon="i-heroicons-building-library" variant="ghost" @click="router.push(`/banking`)">Kontobewegungen
|
|
</UButton>
|
|
</template>
|
|
<template #center>
|
|
<h1 class="text-xl font-medium">Kontobewegung bearbeiten</h1>
|
|
</template>
|
|
<template #badge v-if="itemInfo">
|
|
<UBadge v-if="calculateOpenSum == 0" color="green" variant="subtle">Zugewiesen</UBadge>
|
|
<UBadge v-else color="amber" variant="subtle">Offen</UBadge>
|
|
</template>
|
|
<template #right>
|
|
<ArchiveButton color="error" variant="outline" type="bankstatements" @confirmed="archiveStatement"/>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardPanelContent class="p-0 bg-gray-50 dark:bg-gray-900 overflow-hidden" v-if="!loading">
|
|
<div class="flex flex-col lg:flex-row h-[calc(100vh-4rem)]">
|
|
|
|
<div
|
|
class="w-full lg:w-5/12 xl:w-4/12 flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 h-full overflow-hidden">
|
|
|
|
<div class="p-4 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
|
<div class="flex justify-between items-start mb-2">
|
|
<div>
|
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white line-clamp-1">
|
|
{{ itemInfo.amount > 0 ? itemInfo.debName : itemInfo.credName || 'Unbekannter Partner' }}
|
|
</h2>
|
|
<div class="text-xs text-gray-500 flex gap-2 mt-1">
|
|
<span>{{ dayjs(itemInfo.date).format("DD.MM.YYYY") }}</span>
|
|
<template v-if="itemInfo.debIban || itemInfo.credIban">
|
|
<span>•</span>
|
|
<span class="font-mono">{{ separateIBAN(itemInfo.debIban || itemInfo.credIban) }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span>•</span>
|
|
<span class="italic text-gray-400">Sammelbuchung / Keine IBAN</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-xl font-bold font-mono"
|
|
:class="itemInfo.amount > 0 ? 'text-emerald-600' : 'text-rose-600'">
|
|
{{ displayCurrency(itemInfo.amount) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2 rounded-md cursor-pointer hover:bg-gray-100 transition-colors"
|
|
@click="showMoreText = !showMoreText">
|
|
<p :class="{'line-clamp-2': !showMoreText}">{{ itemInfo.text }}</p>
|
|
<div class="flex justify-center mt-1" v-if="!showMoreText">
|
|
<UIcon name="i-heroicons-chevron-down" class="w-3 h-3 text-gray-400"/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<UFormField label="Notiz" size="sm">
|
|
<UTextarea
|
|
v-model="itemInfo.notes"
|
|
class="w-full"
|
|
:rows="3"
|
|
autoresize
|
|
placeholder="Interne Notiz zu dieser Bankbuchung hinzufügen …"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<div class="flex justify-between text-xs mb-1 font-medium">
|
|
<span :class="calculateOpenSum != 0 ? 'text-amber-600' : 'text-green-600'">
|
|
{{
|
|
calculateOpenSum != 0 ? `${displayCurrency(calculateOpenSum)} offen` : 'Vollständig zugewiesen'
|
|
}}
|
|
</span>
|
|
<span class="text-gray-400">{{
|
|
((Math.abs(itemInfo.amount) - Math.abs(calculateOpenSum)) / Math.abs(itemInfo.amount) * 100).toFixed(0)
|
|
}}%</span>
|
|
</div>
|
|
<UProgress
|
|
:value="Math.abs(itemInfo.amount) - Math.abs(calculateOpenSum)"
|
|
:max="Math.abs(itemInfo.amount)"
|
|
:color="calculateOpenSum != 0 ? 'amber' : 'green'"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto min-h-0 bg-gray-50/50 dark:bg-gray-900">
|
|
<div v-if="itemInfo.statementallocations.length === 0" class="p-8 text-center text-gray-400">
|
|
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mb-2 opacity-20"/>
|
|
<p class="text-sm">Noch keine Buchungen zugeordnet.</p>
|
|
</div>
|
|
|
|
<ul class="divide-y divide-gray-100 dark:divide-gray-800" v-else>
|
|
<li
|
|
v-for="item in itemInfo.statementallocations"
|
|
:key="item.id"
|
|
class="bg-white dark:bg-gray-900 p-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group"
|
|
>
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex gap-3">
|
|
<div class="mt-1">
|
|
<UAvatar :icon="getAllocationIcon(item)" size="xs" :ui="{ rounded: 'rounded-md' }"
|
|
class="bg-gray-100 dark:bg-gray-800 text-gray-500"/>
|
|
</div>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ getAllocationLabel(item) }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div>
|
|
<div v-if="item.costcentre" class="text-xs text-gray-500 mt-1">Kostenstelle: {{ getCostCentreLabel(item.costcentre) }}</div>
|
|
<div v-if="item.datevTaxKey" class="text-xs text-gray-500 mt-1">
|
|
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
|
|
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
|
|
</div>
|
|
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
|
|
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
|
|
</div>
|
|
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
|
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationMonths">{{ item.depreciationMonths }} Monate</UBadge>
|
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationMethod">{{ item.depreciationMethod === 'degressive' ? 'Degressiv' : 'Linear' }}</UBadge>
|
|
</div>
|
|
<div class="flex gap-2 mt-1" v-if="item.createddocument || item.incominginvoice">
|
|
<UButton
|
|
v-if="item.createddocument"
|
|
size="2xs"
|
|
variant="link"
|
|
:padded="false"
|
|
icon="i-heroicons-eye"
|
|
@click="navigateTo(`/createDocument/show/${item.createddocument}`)"
|
|
>Beleg anzeigen
|
|
</UButton>
|
|
<UButton
|
|
v-if="item.incominginvoice"
|
|
size="2xs"
|
|
variant="link"
|
|
:padded="false"
|
|
icon="i-heroicons-eye"
|
|
@click="navigateTo(`/incominginvoices/show/${item.incominginvoice}`)"
|
|
>Beleg anzeigen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
|
|
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
|
|
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-trash"
|
|
color="error"
|
|
variant="ghost"
|
|
size="xs"
|
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
@click="removeAllocation(item.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="w-full lg:w-7/12 xl:w-8/12 flex flex-col h-full overflow-hidden bg-gray-50 dark:bg-gray-950">
|
|
|
|
<div
|
|
class="p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm shrink-0 z-10">
|
|
<div class="grid grid-cols-12 gap-4 items-end">
|
|
<div class="col-span-12 md:col-span-3">
|
|
<UFormField label="Bruttobetrag" size="sm">
|
|
<UInput v-model="manualAllocationSum" type="number" step="0.01">
|
|
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
|
|
</UInput>
|
|
</UFormField>
|
|
</div>
|
|
<div class="col-span-12 md:col-span-5">
|
|
<UFormField label="Konto / Manuelle Buchung" size="sm">
|
|
<div class="flex gap-1">
|
|
<USelectMenu
|
|
class="w-full"
|
|
:items="accounts"
|
|
value-key="id"
|
|
label-key="label"
|
|
v-model="accountToSave"
|
|
:search-input="{ placeholder: 'Konto suchen...' }"
|
|
:filter-fields="['number','label']"
|
|
placeholder="Konto suchen..."
|
|
>
|
|
<template #default>
|
|
<span v-if="accountToSave"
|
|
class="truncate">{{ accounts.find(i => i.id === accountToSave).number }} - {{ accounts.find(i => i.id === accountToSave).label }}</span>
|
|
<span v-else>Direkt verbuchen...</span>
|
|
</template>
|
|
<template #item-label="{ item: option }">
|
|
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
|
|
</template>
|
|
</USelectMenu>
|
|
<UButton
|
|
icon="i-heroicons-check"
|
|
label="Buchen"
|
|
:disabled="!accountToSave || !allocationTaxKey"
|
|
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
</div>
|
|
<div class="col-span-12 md:col-span-4 flex justify-end gap-2 pb-0.5">
|
|
<UButton variant="soft" color="gray" icon="i-heroicons-adjustments-horizontal"
|
|
@click="showMoreWithoutRecipe = !showMoreWithoutRecipe" label="Erweitert"/>
|
|
<UButton variant="soft" color="gray" icon="i-heroicons-pencil-square"
|
|
@click="allocationDescription = allocationDescription ? '' : 'Manuelle Buchung'"
|
|
:color="allocationDescription ? 'primary' : 'gray'"/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<UFormField label="Umsatzsteuer / Vorsteuer" size="sm">
|
|
<USelectMenu v-model="allocationTaxKey" :items="BANK_ALLOCATION_TAX_ITEMS" value-key="value" label-key="label"
|
|
placeholder="Steuer auswählen …" class="w-full" />
|
|
</UFormField>
|
|
<UFormField label="Kostenstelle (optional)" size="sm">
|
|
<div class="flex gap-1">
|
|
<USelectMenu v-model="allocationCostcentre" :items="costcentres" value-key="id" label-key="name"
|
|
:filter-fields="['number', 'name']" placeholder="Keine Kostenstelle" class="w-full">
|
|
<template #default>{{ allocationCostcentre ? getCostCentreLabel(allocationCostcentre) : 'Keine Kostenstelle' }}</template>
|
|
<template #item-label="{ item }">{{ item.number }} - {{ item.name }}</template>
|
|
</USelectMenu>
|
|
<UButton v-if="allocationCostcentre" icon="i-heroicons-x-mark" variant="ghost" aria-label="Kostenstelle entfernen"
|
|
@click="allocationCostcentre = null" />
|
|
</div>
|
|
</UFormField>
|
|
<p v-if="allocationTaxKey && (accountToSave || ownAccountToSave)" class="md:col-span-2 text-xs text-gray-500">
|
|
Netto {{ displayCurrency(allocationAmounts.amountNet) }} · Steuer {{ displayCurrency(allocationAmounts.amountTax) }}
|
|
· Brutto {{ displayCurrency(allocationAmounts.amountGross) }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="showMoreWithoutRecipe"
|
|
class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<UFormField label="Aufwandsart" size="sm">
|
|
<USelectMenu
|
|
v-model="allocationBookingMode"
|
|
:items="EXPENSE_BOOKING_MODE_ITEMS"
|
|
value-key="value"
|
|
label-key="label"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField label="Abschreibungsdauer (Monate)" size="sm" v-if="isDepreciationAllocation">
|
|
<UInput v-model="allocationDepreciationMonths" type="number" min="1" step="1" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Methode" size="sm" v-if="isDepreciationAllocation">
|
|
<USelectMenu
|
|
v-model="allocationDepreciationMethod"
|
|
:items="DEPRECIATION_METHOD_ITEMS"
|
|
value-key="value"
|
|
label-key="label"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3" v-if="isDepreciationAllocation">
|
|
<UFormField label="Start Abschreibung" size="sm">
|
|
<UInput v-model="allocationDepreciationStartDate" type="date" />
|
|
</UFormField>
|
|
|
|
<UFormField label="Restwert" size="sm">
|
|
<UInput v-model="allocationResidualValue" type="number" min="0" step="0.01" />
|
|
</UFormField>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3" v-if="isDepreciationAllocation">
|
|
<UFormField v-if="allocationBookingMode === 'depreciation_bundle'" label="Sammelposten" size="sm">
|
|
<UInput v-model="allocationDepreciationGroup" placeholder="z. B. Betriebsausstattung 2026" />
|
|
</UFormField>
|
|
<UFormField v-else label="Bezeichnung Abschreibung" size="sm">
|
|
<UInput v-model="allocationDepreciationLabel" placeholder="z. B. Werkzeugkoffer" />
|
|
</UFormField>
|
|
<UAlert
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-heroicons-calendar-days"
|
|
title="BWA bucht nur die Abschreibungsrate"
|
|
description="Die Auszahlung erscheint dann nicht sofort als Aufwand, sondern periodisiert über die gewählte Laufzeit."
|
|
/>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<USelectMenu :items="ownaccounts" value-key="id" label-key="name" v-model="ownAccountToSave"
|
|
:search-input="{ placeholder: 'Eigenes Konto' }" :filter-fields="['number','name']" placeholder="Eigenes Konto">
|
|
<template #default>
|
|
{{ ownAccountToSave ? ownaccounts.find(i => i.id === ownAccountToSave).name : 'Eigenes Konto' }}
|
|
</template>
|
|
</USelectMenu>
|
|
<USelectMenu :items="customers" value-key="id" label-key="name"
|
|
v-model="customerAccountToSave" :search-input="{ placeholder: 'Kunde (Guthaben)' }" :filter-fields="['name','customerNumber']" placeholder="Kunde (Guthaben)">
|
|
<template #default>
|
|
{{ customerAccountToSave ? customers.find(i => i.id === customerAccountToSave).name : 'Kunde' }}
|
|
</template>
|
|
</USelectMenu>
|
|
<USelectMenu :items="vendors" value-key="id" label-key="name" v-model="vendorAccountToSave"
|
|
:search-input="{ placeholder: 'Lieferant (Guthaben)' }" :filter-fields="['name','vendorNumber']" placeholder="Lieferant (Guthaben)">
|
|
<template #default>
|
|
{{ vendorAccountToSave ? vendors.find(i => i.id === vendorAccountToSave).name : 'Lieferant' }}
|
|
</template>
|
|
</USelectMenu>
|
|
<div class="md:col-span-3 flex justify-end">
|
|
<UButton size="xs" label="Zuweisen"
|
|
:disabled="!!ownAccountToSave && !allocationTaxKey"
|
|
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="allocationDescription" class="mt-2">
|
|
<UInput v-model="allocationDescription" placeholder="Beschreibung für Buchung..." icon="i-heroicons-pencil"
|
|
size="sm"/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 flex flex-col min-h-0">
|
|
<div class="p-2 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900">
|
|
<UInput
|
|
v-model="searchString"
|
|
icon="i-heroicons-magnifying-glass"
|
|
placeholder="Belege suchen (Nr, Name, Referenz)..."
|
|
:ui="{ icon: { trailing: { pointer: '' } } }"
|
|
@change="tempStore.modifySearchString('bankstatementsedit',searchString)"
|
|
>
|
|
<template #trailing>
|
|
<UButton v-if="searchString" color="gray" variant="link" icon="i-heroicons-x-mark" :padded="false"
|
|
@click="clearSearchString"/>
|
|
</template>
|
|
</UInput>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
|
|
|
<div v-if="loadingDocuments" class="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
|
|
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin"/>
|
|
Offene Belege werden geladen …
|
|
</div>
|
|
|
|
<div v-if="Number(calculateOpenSum) !== 0 && (topEntitySuggestion || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0)" class="rounded-xl border border-primary-200 bg-primary-50/70 dark:bg-primary-900/10 dark:border-primary-800 p-4">
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div v-if="topEntitySuggestion">
|
|
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatischer Vorschlag</div>
|
|
<div class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
|
|
{{ topEntitySuggestion.type === 'customer' ? 'Kunde' : 'Lieferant' }}:
|
|
{{ topEntitySuggestion.number }} - {{ topEntitySuggestion.name }}
|
|
</div>
|
|
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
|
|
{{ topEntitySuggestion.reason }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 mt-1" v-if="statementSuggestions.partnerIban || statementSuggestions.partnerName">
|
|
{{ statementSuggestions.partnerName || 'Unbekannter Partner' }}
|
|
<span v-if="statementSuggestions.partnerIban">| {{ separateIBAN(statementSuggestions.partnerIban) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!topEntitySuggestion" class="mb-3">
|
|
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatische Belegvorschläge</div>
|
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
|
Kein eindeutiger Kunde oder Lieferant erkannt. Vorschläge basieren auf Betrag und Verwendungszweck.
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="suggestedDocuments.length > 0 || suggestedIncomingInvoices.length > 0 || fallbackSuggestedDocuments.length > 0 || fallbackSuggestedIncomingInvoices.length > 0" class="mt-4 grid gap-2">
|
|
<div
|
|
v-for="document in (topEntitySuggestion ? suggestedDocuments : fallbackSuggestedDocuments)"
|
|
:key="`suggested-document-${document.id}`"
|
|
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
|
|
>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ document.documentNumber }}</div>
|
|
<div class="text-xs text-gray-500">{{ document.customer?.name }} | Offen {{ displayCurrency(document.openSum) }}</div>
|
|
<div class="mt-1 flex flex-wrap gap-1" v-if="document.suggestionAmountLabel || document.suggestionPurposeMatches?.length">
|
|
<UBadge v-if="document.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
|
{{ document.suggestionAmountLabel }}
|
|
</UBadge>
|
|
<UBadge v-for="match in document.suggestionPurposeMatches" :key="`doc-match-${document.id}-${match}`" size="xs" color="amber" variant="subtle">
|
|
VWZ: {{ match }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
<UButton
|
|
size="xs"
|
|
color="primary"
|
|
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription || 'Automatischer Vorschlag'})"
|
|
>
|
|
Rechnung zuweisen
|
|
</UButton>
|
|
<UButton
|
|
v-if="canBookCashDiscount('createddocument', document)"
|
|
size="xs"
|
|
color="warning"
|
|
variant="soft"
|
|
@click="openCashDiscountModal('createddocument', document)"
|
|
>
|
|
Mit Skonto
|
|
</UButton>
|
|
<UButton
|
|
size="xs"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="dismissSuggestion(itemInfo.id, document.suggestionKey)"
|
|
>
|
|
Ablehnen
|
|
</UButton>
|
|
</div>
|
|
|
|
<div
|
|
v-for="invoice in (topEntitySuggestion ? suggestedIncomingInvoices : fallbackSuggestedIncomingInvoices)"
|
|
:key="`suggested-invoice-${invoice.id}`"
|
|
class="flex items-center justify-between rounded-lg border border-white/70 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 py-2"
|
|
>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ invoice.vendor?.name }}</div>
|
|
<div class="text-xs text-gray-500">Ref: {{ invoice.reference || '-' }} | Offen {{ displayCurrency(getInvoiceSum(invoice, true)) }}</div>
|
|
<div class="mt-1 flex flex-wrap gap-1" v-if="invoice.suggestionAmountLabel || invoice.suggestionPurposeMatches?.length">
|
|
<UBadge v-if="invoice.suggestionAmountLabel" size="xs" color="emerald" variant="subtle">
|
|
{{ invoice.suggestionAmountLabel }}
|
|
</UBadge>
|
|
<UBadge v-for="match in invoice.suggestionPurposeMatches" :key="`invoice-match-${invoice.id}-${match}`" size="xs" color="amber" variant="subtle">
|
|
VWZ: {{ match }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
<UButton
|
|
size="xs"
|
|
color="error"
|
|
@click="saveAllocation({incominginvoice: invoice.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(invoice,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(invoice,true)), description: allocationDescription || 'Automatischer Vorschlag'})"
|
|
>
|
|
Beleg zuweisen
|
|
</UButton>
|
|
<UButton
|
|
v-if="canBookCashDiscount('incominginvoice', invoice)"
|
|
size="xs"
|
|
color="warning"
|
|
variant="soft"
|
|
@click="openCashDiscountModal('incominginvoice', invoice)"
|
|
>
|
|
Mit Skonto
|
|
</UButton>
|
|
<UButton
|
|
size="xs"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="dismissSuggestion(itemInfo.id, invoice.suggestionKey)"
|
|
>
|
|
Ablehnen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="filteredDocuments.length > 0">
|
|
<h3 class="text-xs font-bold text-gray-500 uppercase mb-2 pl-1 flex items-center gap-2">
|
|
<UIcon name="i-heroicons-document-arrow-up"/>
|
|
Ausgangsrechnungen
|
|
</h3>
|
|
<div
|
|
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
|
|
<div
|
|
v-for="(document, index) in filteredDocuments"
|
|
:key="document.id"
|
|
class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-gray-800 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-3 overflow-hidden">
|
|
<div class="bg-primary-50 dark:bg-primary-900/20 text-primary-600 rounded p-1.5 shrink-0">
|
|
<UIcon name="i-heroicons-document-text" class="w-5 h-5"/>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-sm text-gray-900 dark:text-white truncate">
|
|
{{ document.documentNumber }} <span class="text-gray-400 mx-1">|</span>
|
|
{{ document.customer ? document.customer.name : "Ohne Kunde" }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 flex gap-3 mt-0.5">
|
|
<span class="flex items-center gap-1">
|
|
<UIcon name="i-heroicons-calendar" class="w-3 h-3"/>
|
|
{{ dayjs(document.documentDate || document.date).format("DD.MM.YYYY") }}
|
|
</span>
|
|
<span class="text-primary-600 font-medium"
|
|
v-if="Number(document.openSum) < Number(document.total)">
|
|
Teilzahlung
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 pl-2">
|
|
<div class="text-right">
|
|
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
|
|
<div class="text-xs text-gray-400">Offen</div>
|
|
</div>
|
|
<UTooltip text="Zuweisen">
|
|
<UButton
|
|
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id)"
|
|
icon="i-heroicons-check"
|
|
size="sm"
|
|
color="primary"
|
|
variant="soft"
|
|
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Mit Skonto ausgleichen">
|
|
<UButton
|
|
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
|
|
icon="i-heroicons-receipt-percent"
|
|
size="sm"
|
|
color="warning"
|
|
variant="soft"
|
|
@click="openCashDiscountModal('createddocument', document)"
|
|
/>
|
|
</UTooltip>
|
|
<UButton
|
|
icon="i-heroicons-eye"
|
|
color="gray"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="router.push(`/createDocument/show/${document.id}`)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="filteredIncomingInvoices.length > 0">
|
|
<h3 class="text-xs font-bold text-gray-500 uppercase mb-2 pl-1 flex items-center gap-2">
|
|
<UIcon name="i-heroicons-document-arrow-down"/>
|
|
Eingangsbelege
|
|
</h3>
|
|
<div
|
|
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
|
|
<div
|
|
v-for="(item, index) in filteredIncomingInvoices"
|
|
:key="item.id"
|
|
class="flex items-center justify-between p-3 border-b border-gray-100 dark:border-gray-800 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-3 overflow-hidden">
|
|
<div class="bg-rose-50 dark:bg-rose-900/20 text-rose-600 rounded p-1.5 shrink-0">
|
|
<UIcon name="i-heroicons-receipt-refund" class="w-5 h-5"/>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-medium text-sm text-gray-900 dark:text-white truncate">
|
|
{{ item.vendor ? item.vendor.name : 'Ohne Lieferant' }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 flex gap-3 mt-0.5">
|
|
<span class="flex items-center gap-1">
|
|
<UIcon name="i-heroicons-calendar" class="w-3 h-3"/>
|
|
{{ dayjs(item.date).format("DD.MM.YYYY") }}
|
|
</span>
|
|
<span class="truncate max-w-[150px]" :title="item.reference">Ref: {{ item.reference }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 pl-2">
|
|
<div class="text-right">
|
|
<div class="font-mono font-bold text-sm text-rose-600">
|
|
{{ displayCurrency(getInvoiceSum(item, true)) }}
|
|
</div>
|
|
<div class="text-xs text-gray-400">Offen</div>
|
|
</div>
|
|
<UTooltip text="Zuweisen">
|
|
<UButton
|
|
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id)"
|
|
icon="i-heroicons-check"
|
|
size="sm"
|
|
color="error"
|
|
variant="soft"
|
|
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Mit Skonto ausgleichen">
|
|
<UButton
|
|
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
|
|
icon="i-heroicons-receipt-percent"
|
|
size="sm"
|
|
color="warning"
|
|
variant="soft"
|
|
@click="openCashDiscountModal('incominginvoice', item)"
|
|
/>
|
|
</UTooltip>
|
|
<UButton
|
|
icon="i-heroicons-eye"
|
|
color="gray"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="router.push(`/incominginvoices/show/${item.id}`)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!loadingDocuments && filteredDocuments.length === 0 && filteredIncomingInvoices.length === 0"
|
|
class="text-center py-10 text-gray-400">
|
|
<UIcon name="i-heroicons-magnifying-glass" class="w-8 h-8 mx-auto mb-2 opacity-50"/>
|
|
<p>Keine passenden offenen Belege gefunden.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</UDashboardPanelContent>
|
|
|
|
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
|
|
<template #content>
|
|
<UCard v-if="cashDiscountTarget">
|
|
<template #header>
|
|
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
|
|
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
|
|
</template>
|
|
|
|
<div class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
|
<div class="text-gray-500">Offener Betrag</div>
|
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
|
|
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
|
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
|
|
</div>
|
|
|
|
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
|
|
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
|
|
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
|
|
</UInput>
|
|
</UFormField>
|
|
|
|
<UAlert
|
|
v-if="cashDiscountPercent > 5"
|
|
color="warning"
|
|
variant="subtle"
|
|
icon="i-heroicons-exclamation-triangle"
|
|
title="Ungewöhnlich hoher Skonto"
|
|
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
|
|
/>
|
|
<UAlert
|
|
v-if="cashDiscountError"
|
|
color="error"
|
|
variant="subtle"
|
|
icon="i-heroicons-x-circle"
|
|
:description="cashDiscountError"
|
|
/>
|
|
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
|
|
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
|
|
</div>
|
|
<p class="text-xs text-gray-500">
|
|
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
|
|
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
|
|
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
|
|
</p>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
|
|
<UButton
|
|
color="primary"
|
|
:loading="savingCashDiscount"
|
|
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
|
|
@click="confirmCashDiscount"
|
|
>
|
|
Zahlung und Skonto buchen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Custom scrollbar styling just in case, though usually handled by Tailwind/System */
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #94a3b8;
|
|
}
|
|
|
|
.dark ::-webkit-scrollbar-thumb {
|
|
background: #475569;
|
|
}
|
|
</style>
|