Passt Kassenbuch Ansicht an

Kassenbücher werden nun zuerst tabellarisch angezeigt. Das Erstellen einer Barkasse erfolgt über ein Modal und einzelne Kassenbücher öffnen sich über eine Detailseite.
This commit is contained in:
2026-05-11 17:25:44 +02:00
parent e60188f043
commit d4c39d7d44
2 changed files with 182 additions and 101 deletions

View File

@@ -0,0 +1,428 @@
<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>
<UDashboardPanelContent>
<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>
</UDashboardNavbar>
<div v-if="loading" class="py-10 text-center text-gray-500">Lade Kassenbuch...</div>
<div v-else-if="selectedCashbook" class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500">Aktuelle Kasse</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{{ selectedCashbook.name }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500">Kontennummer</div>
<div class="mt-1 font-mono text-lg font-semibold text-gray-900 dark:text-white">{{ selectedCashbook.datevNumber }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500">Kassenbestand</div>
<div class="mt-1 text-lg font-semibold" :class="currentBalance < 0 ? 'text-red-600' : 'text-emerald-600'">
{{ displayCurrency(currentBalance) }}
</div>
</UCard>
</div>
<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>