Files
FEDEO/frontend/pages/banking/statements/[mode]/[[id]].vue
florianfederspiel 59fdedfaa0
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 1m7s
Fix for Rendering in Bank Booking
2026-02-02 18:03:41 +01:00

601 lines
27 KiB
Vue

<script setup>
import dayjs from "dayjs";
// 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 customers = ref([])
const vendors = ref([])
const createddocuments = ref([])
const incominginvoices = ref([])
const accounts = ref([])
const ownaccounts = ref([])
const loading = ref(true)
const setup = async () => {
loading.value = true
if (route.params.id) {
itemInfo.value = await useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))", undefined, undefined, true)
console.log(itemInfo.value)
}
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
manualAllocationSum.value = calculateOpenSum.value
createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"))
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
incominginvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(id,name)")).filter(i => i.state === "Gebucht")
accounts.value = (await useEntities("accounts").selectSpecial("*", "number", true))
ownaccounts.value = (await useEntities("ownaccounts").select())
customers.value = (await useEntities("customers").select())
vendors.value = (await useEntities("vendors").select())
openDocuments.value = documents.filter(i => i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, createddocuments.value).toFixed(2))
openDocuments.value = openDocuments.value.map(i => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
openSum: (useSum().getCreatedDocumentSum(i, createddocuments.value) - Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0))).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 = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
loading.value = false
}
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 = 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 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 showMoreWithoutRecipe = ref(false)
const showMoreText = ref(false)
const saveAllocation = async (allocation) => {
const res = await useNuxtApp().$api("/api/banking/statements", {
method: "POST",
body: {data: allocation}
})
if (res) {
await setup()
accountToSave.value = null
vendorAccountToSave.value = null
customerAccountToSave.value = null
ownAccountToSave.value = null
// allocationDescription.value = null // Optional: Beschreibung behalten für nächste Buchung?
}
}
const removeAllocation = async (allocationId) => {
await useNuxtApp().$api(`/api/banking/statements/${allocationId}`, {
method: "DELETE"
})
await setup()
}
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 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'
}
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="rose" 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>&bull;</span>
<span class="font-mono">{{ separateIBAN(itemInfo.debIban || itemInfo.credIban) }}</span>
</template>
<template v-else>
<span>&bull;</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-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 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>
<UButton
icon="i-heroicons-trash"
color="rose"
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">
<UFormGroup label="Betrag" size="sm">
<UInput v-model="manualAllocationSum" type="number" step="0.01">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput>
</UFormGroup>
</div>
<div class="col-span-12 md:col-span-5">
<UFormGroup label="Konto / Manuelle Buchung" size="sm">
<div class="flex gap-1">
<USelectMenu
class="w-full"
:options="accounts"
value-attribute="id"
option-attribute="label"
v-model="accountToSave"
searchable
:search-attributes="['number','label']"
placeholder="Konto suchen..."
>
<template #label>
<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 #option="{option}">
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
</template>
</USelectMenu>
<UTooltip text="Manuell Buchen">
<UButton
icon="i-heroicons-plus"
:disabled="!accountToSave"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/>
</UTooltip>
</div>
</UFormGroup>
</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 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 grid grid-cols-1 md:grid-cols-3 gap-3">
<USelectMenu :options="ownaccounts" value-attribute="id" option-attribute="name" v-model="ownAccountToSave"
searchable placeholder="Eigenes Konto">
<template #label>
{{ ownAccountToSave ? ownaccounts.find(i => i.id === ownAccountToSave).name : 'Eigenes Konto' }}
</template>
</USelectMenu>
<USelectMenu :options="customers" value-attribute="id" option-attribute="name"
v-model="customerAccountToSave" searchable placeholder="Kunde (Guthaben)">
<template #label>
{{ customerAccountToSave ? customers.find(i => i.id === customerAccountToSave).name : 'Kunde' }}
</template>
</USelectMenu>
<USelectMenu :options="vendors" value-attribute="id" option-attribute="name" v-model="vendorAccountToSave"
searchable placeholder="Lieferant (Guthaben)">
<template #label>
{{ 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"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/>
</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="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.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>
<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="rose"
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>
<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="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>
</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>