Die Kassenbuch-Listenansicht rendert die DashboardNavbar nun wie andere Listenseiten direkt oben. Die Detailansicht nutzt dieselbe Struktur und hält den Inhalt im PanelContent.
425 lines
16 KiB
Vue
425 lines
16 KiB
Vue
<script setup>
|
|
import dayjs from "dayjs"
|
|
|
|
const toast = useToast()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const loading = ref(true)
|
|
const savingBooking = ref(false)
|
|
const cashbooks = ref([])
|
|
const bookings = ref([])
|
|
const accounts = ref([])
|
|
const customers = ref([])
|
|
const vendors = ref([])
|
|
const ownaccounts = ref([])
|
|
const incomingInvoices = ref([])
|
|
const selectedCashbookId = computed(() => Number(route.params.id))
|
|
const counterSearch = ref("")
|
|
const expandedGroups = ref([])
|
|
|
|
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 form = reactive({
|
|
date: dayjs().format("YYYY-MM-DD"),
|
|
direction: "expense",
|
|
amount: null,
|
|
counter: "",
|
|
datevTaxKey: "__none__",
|
|
description: ""
|
|
})
|
|
|
|
const displayCurrency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
|
|
const normalizeSearch = (value) => String(value || "").toLowerCase().trim()
|
|
|
|
const selectedCashbook = computed(() => cashbooks.value.find((item) => item.id === selectedCashbookId.value) || null)
|
|
const currentBalance = computed(() => {
|
|
const openingBalance = Number(selectedCashbook.value?.balance || 0)
|
|
const movementSum = bookings.value.reduce((sum, booking) => sum + Number(booking.amount || 0), 0)
|
|
return openingBalance + movementSum
|
|
})
|
|
|
|
const getIncomingInvoiceGross = (invoice) => {
|
|
return 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 buildEntries = (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 entryGroups = computed(() => ([
|
|
{
|
|
key: "account",
|
|
label: "Sachkonten",
|
|
entries: buildEntries(accounts.value, "account", (item) => `${item.number} - ${item.label}`)
|
|
},
|
|
{
|
|
key: "vendor",
|
|
label: "Kreditoren",
|
|
entries: buildEntries(vendors.value, "vendor", (item) => `${item.vendorNumber || "ohne Nr."} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "customer",
|
|
label: "Debitoren",
|
|
entries: buildEntries(customers.value, "customer", (item) => `${item.customerNumber || "ohne Nr."} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "ownaccount",
|
|
label: "Zusätzliche Konten",
|
|
entries: buildEntries(ownaccounts.value, "ownaccount", (item) => `${item.number} - ${item.name}`)
|
|
},
|
|
{
|
|
key: "incominginvoice",
|
|
label: "Eingangsbelege",
|
|
entries: buildEntries(incomingInvoices.value, "incominginvoice", (item) => `${item.reference || "Ohne Referenz"} - ${item.vendor?.name || "Ohne Lieferant"} - Offen ${displayCurrency(getIncomingInvoiceOpenAmount(item))}`)
|
|
}
|
|
]))
|
|
|
|
const groupedCounterEntries = computed(() =>
|
|
entryGroups.value.map((group) => ({
|
|
...group,
|
|
entries: group.entries.filter((entry) => {
|
|
const search = normalizeSearch(counterSearch.value)
|
|
if (!search) return true
|
|
return [entry.number, entry.name, entry.label, entry.typeLabel]
|
|
.some((value) => normalizeSearch(value).includes(search))
|
|
})
|
|
})).filter((group) => group.entries.length > 0)
|
|
)
|
|
|
|
const selectedCounter = computed(() => entryGroups.value.flatMap((group) => group.entries).find((entry) => entry.key === form.counter))
|
|
|
|
const isGroupExpanded = (groupKey) => expandedGroups.value.includes(groupKey)
|
|
const toggleGroupExpanded = (groupKey) => {
|
|
if (expandedGroups.value.includes(groupKey)) {
|
|
expandedGroups.value = expandedGroups.value.filter((item) => item !== groupKey)
|
|
return
|
|
}
|
|
expandedGroups.value = [...expandedGroups.value, groupKey]
|
|
}
|
|
|
|
const visibleEntries = (group) => {
|
|
if (group.entries.length <= 5 || isGroupExpanded(group.key)) return group.entries
|
|
return group.entries.slice(0, 5)
|
|
}
|
|
|
|
const getBookingCounter = (booking) => {
|
|
if (booking.incominginvoice) {
|
|
return {
|
|
type: "Eingangsbeleg",
|
|
number: booking.incominginvoice.reference || "",
|
|
name: booking.incominginvoice.vendor?.name || booking.incominginvoice.description || ""
|
|
}
|
|
}
|
|
|
|
const map = [
|
|
["account", "Sachkonto"],
|
|
["vendor", "Kreditor"],
|
|
["customer", "Debitor"],
|
|
["ownaccount", "Zusätzliches Konto"]
|
|
]
|
|
|
|
for (const [key, type] of map) {
|
|
const item = booking[key]
|
|
if (!item) continue
|
|
return {
|
|
type,
|
|
number: item.number || item.vendorNumber || item.customerNumber || "",
|
|
name: item.label || item.name || ""
|
|
}
|
|
}
|
|
|
|
return { type: "", number: "", name: "" }
|
|
}
|
|
|
|
const loadBookings = async () => {
|
|
if (!selectedCashbookId.value || !Number.isFinite(selectedCashbookId.value)) {
|
|
bookings.value = []
|
|
return
|
|
}
|
|
|
|
bookings.value = await useNuxtApp().$api(`/api/banking/cashbooks/${selectedCashbookId.value}/bookings`)
|
|
}
|
|
|
|
const loadData = async () => {
|
|
loading.value = true
|
|
const [cashbookRows, accountRows, customerRows, vendorRows, ownaccountRows, incomingInvoiceRows] = await Promise.all([
|
|
useNuxtApp().$api("/api/banking/cashbooks"),
|
|
useEntities("accounts").selectSpecial("*", "number", true),
|
|
useEntities("customers").select(),
|
|
useEntities("vendors").select(),
|
|
useEntities("ownaccounts").select(),
|
|
useEntities("incominginvoices").select("*, vendor(*), statementallocations(id,amount)")
|
|
])
|
|
|
|
cashbooks.value = cashbookRows || []
|
|
accounts.value = accountRows || []
|
|
customers.value = customerRows || []
|
|
vendors.value = vendorRows || []
|
|
ownaccounts.value = ownaccountRows || []
|
|
incomingInvoices.value = (incomingInvoiceRows || [])
|
|
.filter((invoice) => invoice.state === "Gebucht" && !invoice.archived)
|
|
.filter((invoice) => getIncomingInvoiceOpenAmount(invoice) > 0.004)
|
|
|
|
await loadBookings()
|
|
loading.value = false
|
|
}
|
|
|
|
const saveBooking = async () => {
|
|
if (!selectedCashbookId.value || !form.date || !form.amount || !form.counter) {
|
|
toast.add({ title: "Bitte Kasse, Datum, Betrag und Gegenkonto auswählen.", color: "warning" })
|
|
return
|
|
}
|
|
|
|
const [counterType, counterId] = String(form.counter).split(":")
|
|
savingBooking.value = true
|
|
try {
|
|
await useNuxtApp().$api(`/api/banking/cashbooks/${selectedCashbookId.value}/bookings`, {
|
|
method: "POST",
|
|
body: {
|
|
date: form.date,
|
|
direction: form.direction,
|
|
amount: Number(form.amount),
|
|
counterType,
|
|
counterId,
|
|
datevTaxKey: form.datevTaxKey === "__none__" ? null : form.datevTaxKey,
|
|
description: form.description
|
|
}
|
|
})
|
|
toast.add({ title: "Kassenbuchung erstellt." })
|
|
form.amount = null
|
|
form.counter = ""
|
|
form.datevTaxKey = "__none__"
|
|
form.description = ""
|
|
counterSearch.value = ""
|
|
expandedGroups.value = []
|
|
await loadBookings()
|
|
} finally {
|
|
savingBooking.value = false
|
|
}
|
|
}
|
|
|
|
const deleteBooking = async (booking) => {
|
|
await useNuxtApp().$api(`/api/banking/cashbook-bookings/${booking.id}`, { method: "DELETE" })
|
|
toast.add({ title: "Kassenbuchung gelöscht." })
|
|
await loadBookings()
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar :title="selectedCashbook ? selectedCashbook.name : 'Kassenbuch'">
|
|
<template #left>
|
|
<UButton icon="i-heroicons-arrow-left" variant="ghost" color="neutral" @click="router.push('/accounting/cashbooks')">
|
|
Kassenbücher
|
|
</UButton>
|
|
</template>
|
|
<template #right>
|
|
<div v-if="selectedCashbook" class="flex flex-wrap items-center justify-end gap-2">
|
|
<UBadge color="neutral" variant="subtle">
|
|
{{ selectedCashbook.name }}
|
|
</UBadge>
|
|
<UBadge color="neutral" variant="subtle">
|
|
Konto {{ selectedCashbook.datevNumber }}
|
|
</UBadge>
|
|
<UBadge :color="currentBalance < 0 ? 'error' : 'success'" variant="subtle">
|
|
Bestand {{ displayCurrency(currentBalance) }}
|
|
</UBadge>
|
|
</div>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardPanelContent>
|
|
<div v-if="loading" class="py-10 text-center text-gray-500">Lade Kassenbuch...</div>
|
|
<div v-else-if="selectedCashbook" class="space-y-6">
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h2 class="font-semibold text-gray-900 dark:text-white">Neue Kassenbuchung</h2>
|
|
<p class="text-sm text-gray-500">Einnahmen erhöhen den Kassenbestand, Ausgaben verringern ihn.</p>
|
|
</div>
|
|
<div class="grid gap-3 sm:grid-cols-3">
|
|
<UFormField label="Buchungsdatum">
|
|
<UInput v-model="form.date" type="date" />
|
|
</UFormField>
|
|
<UFormField label="Betrag">
|
|
<UInput v-model="form.amount" type="number" min="0" step="0.01" placeholder="0,00" />
|
|
</UFormField>
|
|
<UFormField label="DATEV-Steuerschlüssel">
|
|
<USelect
|
|
v-model="form.datevTaxKey"
|
|
:items="DATEV_TAX_KEY_ITEMS"
|
|
value-key="value"
|
|
label-key="label"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="grid gap-6 xl:grid-cols-[240px_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="form.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="form.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="form.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="form.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="space-y-4">
|
|
<div v-for="group in groupedCounterEntries" :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 visibleEntries(group)"
|
|
:key="entry.key"
|
|
type="button"
|
|
class="rounded-lg border px-3 py-2 text-left transition"
|
|
:class="form.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="form.counter = entry.key"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-mono text-sm">{{ entry.number }}</span>
|
|
<span class="text-sm">{{ entry.name }}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<UButton
|
|
v-if="group.entries.length > 5"
|
|
size="xs"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:label="isGroupExpanded(group.key) ? 'Weniger anzeigen' : `${group.entries.length - 5} weitere anzeigen`"
|
|
@click="toggleGroupExpanded(group.key)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="selectedCounter"
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-heroicons-arrows-right-left"
|
|
:title="form.direction === 'income' ? `${selectedCashbook.datevNumber} an ${selectedCounter.number}` : `${selectedCounter.number} an ${selectedCashbook.datevNumber}`"
|
|
:description="`${selectedCounter.typeLabel.slice(0, -1)} ${selectedCounter.name} wird als Gegenkonto verwendet.`"
|
|
/>
|
|
|
|
<UFormField label="Beschreibung">
|
|
<UTextarea v-model="form.description" placeholder="z. B. Bareinkauf Büromaterial" autoresize class="w-full" />
|
|
</UFormField>
|
|
|
|
<div class="flex justify-end">
|
|
<UButton color="primary" icon="i-heroicons-check" :loading="savingBooking" @click="saveBooking">
|
|
Kassenbuchung erstellen
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<div>
|
|
<h2 class="font-semibold text-gray-900 dark:text-white">Kassenbewegungen</h2>
|
|
<p class="text-sm text-gray-500">Alle Buchungen dieser Barkasse mit Gegenkonto.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="bookings.length === 0" class="py-10 text-center text-gray-500">Noch keine Kassenbuchungen erfasst.</div>
|
|
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
<div v-for="booking in bookings" :key="booking.id" class="py-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
|
<span class="font-medium">{{ dayjs(booking.date).format("DD.MM.YYYY") }}</span>
|
|
<UBadge :color="Number(booking.amount) >= 0 ? 'success' : 'error'" variant="subtle">
|
|
{{ Number(booking.amount) >= 0 ? "Einnahme" : "Ausgabe" }}
|
|
</UBadge>
|
|
<span class="font-mono font-semibold">{{ displayCurrency(Math.abs(Number(booking.amount || 0))) }}</span>
|
|
<span class="text-gray-500">{{ getBookingCounter(booking).type }}</span>
|
|
<span class="font-mono">{{ getBookingCounter(booking).number }}</span>
|
|
<span>{{ getBookingCounter(booking).name }}</span>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-trash"
|
|
color="error"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="deleteBooking(booking)"
|
|
/>
|
|
</div>
|
|
<div v-if="booking.text" class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
{{ booking.text }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
</div>
|
|
|
|
<UCard v-else>
|
|
<div class="py-16 text-center text-gray-500">Dieses Kassenbuch wurde nicht gefunden.</div>
|
|
</UCard>
|
|
</UDashboardPanelContent>
|
|
</template>
|