Files
FEDEO/frontend/pages/incomingInvoices/[mode]/[id].vue
florianfederspiel 94ab3350ec Belegansichten um Bankbuchungsdatum erweitern #110
Zeigt Bankbuchungsdaten in Ausgangsbelegen direkt an und ergänzt Bankdetails im Modal. Überarbeitet die Eingangsbeleg-Showansicht zu einer echten Lesedarstellung mit Bankbuchungsdaten und Positionsübersicht.
2026-05-11 18:04:05 +02:00

950 lines
39 KiB
Vue

<script setup>
import InputGroup from "~/components/InputGroup.vue";
import dayjs from "dayjs";
import { parseDate } from "@internationalized/date"
import { useDraggable } from '@vueuse/core'
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
createIncomingInvoiceAccount,
ensureDepreciationDefaults,
isDepreciationBookingMode,
normalizeIncomingInvoiceAccounts
} from "~/composables/useDepreciation"
// --- Standard Setup & Data ---
const dataStore = useDataStore()
const route = useRoute()
const mode = ref(route.params.mode)
const toast = useToast()
// --- Page Leave Logic ---
const isModified = ref(false) // Speichert, ob Änderungen vorgenommen wurden
// State für das PDF Fenster
const isPdfDetached = ref(false)
const pdfEl = ref(null)
const { style: pdfStyle } = useDraggable(pdfEl, {
initialValue: { x: 50, y: 100 },
})
const itemInfo = ref({
vendor: null,
expense: true,
reference: "",
date: null,
dueDate: null,
paymentType: "Überweisung",
description: "",
state: "Entwurf",
accounts: [
createIncomingInvoiceAccount()
]
})
const costcentres = ref([])
const vendors = ref([])
const accounts = ref([])
const loadedFileId = ref(null)
const invoiceFiles = ref([])
const paymentTypeItems = ['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']
const files = useFiles()
const setup = async () => {
// 1. Daten laden
costcentres.value = await useEntities("costcentres").select()
vendors.value = await useEntities("vendors").select()
accounts.value = await useEntities("accounts").selectSpecial()
const invoiceData = await useEntities("incominginvoices").selectSingle(route.params.id, "*, files(*), statementallocations(*, bs_id(*))")
// 2. Mapping
itemInfo.value = {
...invoiceData,
vendor: invoiceData.vendor?.id || invoiceData.vendor,
accounts: normalizeIncomingInvoiceAccounts(invoiceData.accounts || [], invoiceData.date)
}
// Fallback Accounts
if(itemInfo.value.accounts.length === 0) {
itemInfo.value.accounts.push(createIncomingInvoiceAccount({ depreciationStartDate: itemInfo.value.date || null }))
}
// Datei laden
if (itemInfo.value.files && itemInfo.value.files.length > 0) {
invoiceFiles.value = await files.selectSomeDocuments(itemInfo.value.files.map((file) => file.id))
const latestPdf = [...invoiceFiles.value].reverse().find((file) => file?.path?.toLowerCase().includes('.pdf'))
loadedFileId.value = latestPdf?.id || null
}
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
// 3. Watcher initialisieren (erst NACH dem Laden der Daten)
// Wir warten einen Tick, damit die Initialisierung nicht als Änderung zählt
await nextTick()
isModified.value = false // Sicherstellen, dass Status sauber ist
watch(itemInfo, () => {
if (mode.value !== 'show') {
isModified.value = true
}
}, { deep: true })
}
setup()
watch(() => itemInfo.value.date, (value) => {
;(itemInfo.value.accounts || []).forEach((account) => {
if (isDepreciationItem(account) && !account.depreciationStartDate) {
account.depreciationStartDate = value || null
}
})
})
// --- Berechnungslogik ---
const useNetMode = ref(false)
const taxOptions = ref([
{ label: "19% USt", percentage: 19, key: "19" },
{ label: "7% USt", percentage: 7, key: "7" },
{ label: "IG Erwerb 19%", percentage: 0, key: "19I" },
{ label: "IG Erwerb 7%", percentage: 0, key: "7I" },
{ label: "§13b UStG", percentage: 0, key: "13B" },
{ label: "Keine USt", percentage: 0, key: "null" },
])
const getCalendarValue = (value) => {
if (!value) return undefined
const formatted = dayjs(value).format('YYYY-MM-DD')
return formatted ? parseDate(formatted) : undefined
}
const setDateField = (field, value) => {
itemInfo.value[field] = value ? dayjs(value.toString()).toDate() : null
}
const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate()
}
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")}`
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
const getBankBookingDate = (allocation) => {
const statement = getStatementLike(allocation)
return statement?.date || statement?.valueDate || allocation?.manualBookingDate || null
}
const bankBookingDates = computed(() => [...new Set(
(itemInfo.value.statementallocations || [])
.map(getBankBookingDate)
.filter(Boolean)
)].sort())
const bankBookingDateLabel = computed(() => {
if (bankBookingDates.value.length === 0) return "Nicht zugewiesen"
if (bankBookingDates.value.length === 1) return formatDate(bankBookingDates.value[0])
return bankBookingDates.value.map(formatDate).join(", ")
})
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
const getAccountLabel = (item) => {
const account = accounts.value.find((entry) => entry.id === item.account)
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
}
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
const totalCalculated = computed(() => {
let totalNet = 0
let totalAmount19Tax = 0
let totalAmount7Tax = 0
let totalGross = 0
itemInfo.value.accounts.forEach(account => {
if(account.amountNet) totalNet += Number(account.amountNet)
if(account.taxType === "19" && account.amountTax) {
totalAmount19Tax += Number(account.amountTax)
} else if(account.taxType === "7" && account.amountTax) {
totalAmount7Tax += Number(account.amountTax)
}
})
totalGross = Number(totalNet + totalAmount19Tax + totalAmount7Tax)
return { totalNet, totalAmount19Tax, totalAmount7Tax, totalGross }
})
const hasAmount = (value) => value !== null && value !== undefined && value !== ""
const hasValidNumber = (value) => hasAmount(value) && Number.isFinite(Number(value))
const isDepreciationItem = (item) => isDepreciationBookingMode(item?.bookingMode)
const updateBookingMode = (item) => {
ensureDepreciationDefaults(item, itemInfo.value.date)
}
const recalculateItem = (item, source) => {
const taxRate = Number(taxOptions.value.find(i => i.key === item.taxType)?.percentage || 0);
const calculateFromNet = () => {
if(!hasAmount(item.amountNet)) return
item.amountTax = Number((item.amountNet * (taxRate/100)).toFixed(2))
item.amountGross = Number((Number(item.amountNet) + item.amountTax).toFixed(2))
}
const calculateFromGross = () => {
if(!hasAmount(item.amountGross)) return
item.amountNet = Number((item.amountGross / (1 + taxRate/100)).toFixed(2))
item.amountTax = Number((item.amountGross - item.amountNet).toFixed(2))
}
if (source === 'net') {
calculateFromNet()
} else if (source === 'gross') {
calculateFromGross()
} else if (source === 'taxType' || source === 'manual') {
if(hasAmount(item.amountNet)) calculateFromNet()
else if(hasAmount(item.amountGross)) calculateFromGross()
}
}
const moveGrossToNet = (item) => {
if(!hasAmount(item.amountGross)) return
item.amountNet = Number(item.amountGross)
recalculateItem(item, 'net')
}
// --- Saving ---
const updateIncomingInvoice = async (setBooked = false) => {
if (setBooked && hasBlockingIncomingInvoiceErrors.value) {
toast.add({
title: "Buchen nicht möglich",
description: "Bitte beheben Sie zuerst die rot markierten Pflichtfehler.",
color: "error"
})
return
}
let item = { ...itemInfo.value }
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
delete item.files
item.state = setBooked ? "Gebucht" : "Entwurf"
await useEntities('incominginvoices').update(itemInfo.value.id, item, !setBooked)
// WICHTIG: Nach dem Speichern ist das Formular "sauber"
isModified.value = false
}
const findIncomingInvoiceErrors = computed(() => {
const errors = []
const i = itemInfo.value
if(!i.vendor) errors.push({message: "Es ist kein Lieferant ausgewählt", type: "breaking"})
if(!i.reference || !String(i.reference).trim()) errors.push({message: "Es ist keine Referenz angegeben", type: "breaking"})
if(!i.date) errors.push({message: "Es ist kein Datum ausgewählt", type: "breaking"})
if(!i.accounts || i.accounts.length === 0) errors.push({message: "Es ist keine Position vorhanden", type: "breaking"})
i.accounts.forEach((account, idx) => {
ensureDepreciationDefaults(account, i.date)
if(!account.account) errors.push({message: `Pos ${idx+1}: Keine Kategorie`, type: "breaking"})
if(!hasValidNumber(account.amountNet) && !hasValidNumber(account.amountGross)) {
errors.push({message: `Pos ${idx+1}: Kein gültiger Betrag`, type: "breaking"})
}
if(!account.taxType) errors.push({message: `Pos ${idx+1}: Kein Steuerschlüssel`, type: "breaking"})
if(hasValidNumber(account.amountNet) && !hasValidNumber(account.amountTax)) {
errors.push({message: `Pos ${idx+1}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning"})
}
if(isDepreciationItem(account) && !Number(account.depreciationMonths)) {
errors.push({message: `Pos ${idx+1}: Abschreibungsdauer fehlt`, type: "breaking"})
}
if(account.bookingMode === "depreciation_bundle" && !String(account.depreciationGroup || "").trim()) {
errors.push({message: `Pos ${idx+1}: Sammelposten benötigt einen Gruppennamen`, type: "breaking"})
}
})
const order = { breaking: 0, warning: 1 }
return errors.sort((a,b) => order[a.type] - order[b.type])
})
const blockingIncomingInvoiceErrors = computed(() => findIncomingInvoiceErrors.value.filter(i => i.type === "breaking"))
const warningIncomingInvoiceErrors = computed(() => findIncomingInvoiceErrors.value.filter(i => i.type === "warning"))
const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceErrors.value.length > 0)
</script>
<template>
<UDashboardNavbar>
<template #left>
<UButton
icon="i-heroicons-chevron-left"
@click="navigateTo(`/incomingInvoices`)"
variant="outline"
>
Eingangsbelege
</UButton>
</template>
<template #center>
<h1 class="text-xl font-medium">
{{`Eingangsbeleg ${mode === 'show' ? 'anzeigen' : 'bearbeiten'}`}}
</h1>
</template>
<template #right>
<ArchiveButton
v-if="mode !== 'show'"
color="error"
variant="outline"
type="incominginvoices"
@confirmed="useEntities('incominginvoices').archive(route.params.id)"
/>
<UButton v-if="mode !== 'show'" @click="updateIncomingInvoice(false)">
Speichern
</UButton>
<UButton
v-if="mode !== 'show'"
@click="updateIncomingInvoice(true)"
:disabled="hasBlockingIncomingInvoiceErrors"
>
Speichern & Buchen
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="p-0 overflow-hidden relative">
<div class="flex h-[calc(100vh-4rem)]">
<div
v-if="!isPdfDetached && loadedFileId"
class="w-1/2 h-full bg-gray-100 border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700 flex flex-col"
>
<div class="p-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 flex justify-between items-center">
<span class="text-xs text-gray-500 font-mono pl-2">Vorschau</span>
<UTooltip text="Dokument lösen (Draggable Window)">
<UButton
icon="i-heroicons-arrows-pointing-out"
variant="ghost"
size="xs"
@click="isPdfDetached = true"
>
Lösen
</UButton>
</UTooltip>
</div>
<div class="flex-grow overflow-hidden relative">
<PDFViewer
:file-id="loadedFileId"
location="split_view"
class="h-full w-full"
/>
</div>
</div>
<div
class="flex flex-col bg-white dark:bg-gray-900 overflow-y-auto transition-all duration-300"
:class="(!isPdfDetached && loadedFileId) ? 'w-1/2' : 'w-full'"
>
<div class="p-6 max-w-4xl mx-auto w-full space-y-6 pb-20">
<UButton
v-if="isPdfDetached && loadedFileId"
icon="i-heroicons-arrows-pointing-in"
variant="outline"
class="mb-4"
@click="isPdfDetached = false"
>
Dokument andocken
</UButton>
<UAlert
v-if="mode !== 'show' && findIncomingInvoiceErrors.length > 0"
title="Prüfung erforderlich"
:color="hasBlockingIncomingInvoiceErrors ? 'error' : 'orange'"
variant="soft"
icon="i-heroicons-exclamation-triangle"
>
<template #description>
<div class="space-y-3 text-sm mt-1">
<div v-if="blockingIncomingInvoiceErrors.length > 0">
<p class="font-semibold text-rose-700 dark:text-rose-300">Pflichtfehler</p>
<ul class="list-disc list-inside mt-1">
<li
v-for="(err, idx) in blockingIncomingInvoiceErrors"
:key="`blocking-${idx}-${err.message}`"
>
{{ err.message }}
</li>
</ul>
</div>
<div v-if="warningIncomingInvoiceErrors.length > 0">
<p class="font-semibold text-orange-700 dark:text-orange-300">Hinweise</p>
<ul class="list-disc list-inside mt-1">
<li
v-for="(err, idx) in warningIncomingInvoiceErrors"
:key="`warning-${idx}-${err.message}`"
>
{{ err.message }}
</li>
</ul>
</div>
</div>
</template>
</UAlert>
<div v-if="mode === 'show'" class="space-y-6">
<div class="rounded-md border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<p class="text-sm text-gray-500">Eingangsbeleg</p>
<h2 class="text-xl font-semibold">{{ itemInfo.reference || "-" }}</h2>
</div>
<UBadge :color="itemInfo.state === 'Gebucht' ? 'primary' : 'neutral'" variant="soft">
{{ itemInfo.state || "Entwurf" }}
</UBadge>
</div>
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div>
<dt class="text-gray-500">Lieferant / Partner</dt>
<dd class="font-medium">{{ vendorName }}</dd>
</div>
<div>
<dt class="text-gray-500">Zahlart</dt>
<dd class="font-medium">{{ itemInfo.paymentType || "-" }}</dd>
</div>
<div>
<dt class="text-gray-500">Rechnungsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.date) }}</dd>
</div>
<div>
<dt class="text-gray-500">Fälligkeitsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
</div>
<div>
<dt class="text-gray-500">Bankbuchungsdatum</dt>
<dd class="font-medium">{{ bankBookingDateLabel }}</dd>
</div>
<div>
<dt class="text-gray-500">Art</dt>
<dd class="font-medium">{{ itemInfo.expense ? "Ausgabe" : "Einnahme" }}</dd>
</div>
<div class="md:col-span-2">
<dt class="text-gray-500">Beschreibung / Notiz</dt>
<dd class="font-medium whitespace-pre-wrap">{{ itemInfo.description || "-" }}</dd>
</div>
</dl>
</div>
<div
v-if="itemInfo.statementallocations?.length > 0"
class="rounded-md border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900"
>
<h3 class="mb-4 font-semibold">Bankbuchungen</h3>
<div class="space-y-3">
<div
v-for="allocation in itemInfo.statementallocations"
:key="allocation.id"
class="grid grid-cols-1 gap-2 rounded-md border border-gray-200 p-3 text-sm dark:border-gray-800 md:grid-cols-3"
>
<div>
<p class="text-xs text-gray-500">Bankbuchungsdatum</p>
<p class="font-medium">{{ formatDate(getBankBookingDate(allocation)) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>
<p class="font-medium">{{ getStatementLike(allocation)?.text || allocation.description || "-" }}</p>
</div>
</div>
</div>
</div>
</div>
<UCard v-if="mode !== 'show'">
<template #header>
<div class="flex justify-between items-center">
<h3 class="font-semibold">Stammdaten</h3>
<div class="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
<UButton size="xs" :variant="itemInfo.expense ? 'solid' : 'ghost'" color="error" @click="itemInfo.expense = true" :disabled="mode === 'show'">Ausgabe</UButton>
<UButton size="xs" :variant="!itemInfo.expense ? 'solid' : 'ghost'" color="emerald" @click="itemInfo.expense = false" :disabled="mode === 'show'">Einnahme</UButton>
</div>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Lieferant / Partner" class="md:col-span-2">
<div class="flex gap-2">
<USelectMenu
class="w-full"
v-model="itemInfo.vendor"
:items="vendors"
label-key="name"
value-key="id"
:search-input="{ placeholder: 'Lieferant suchen...' }"
:disabled="mode === 'show'"
:filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'"
>
<template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
</template>
<template #item="{ item: option }">
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
</template>
</USelectMenu>
<UButton
v-if="mode !== 'show'"
icon="i-heroicons-x-mark"
variant="outline"
color="error"
:disabled="!itemInfo.vendor"
@click="itemInfo.vendor = null"
/>
<EntityModalButtons
v-if="mode !== 'show'"
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
/>
</div>
</UFormField>
<UFormField label="Rechnungsnummer">
<UInput class="w-full" v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" :color="itemInfo.reference ? 'primary' : 'error'" />
</UFormField>
<UFormField label="Zahlart">
<USelectMenu v-model="itemInfo.paymentType" :items="paymentTypeItems" :disabled="mode === 'show'" class="w-full" />
</UFormField>
<UFormField label="Rechnungsdatum">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.date)"
:disabled="mode === 'show'"
:color="itemInfo.date ? 'neutral' : 'error'"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.date)"
@update:model-value="(value) => { setDateField('date', value); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
:week-starts-on="1"
/>
<div class="flex justify-end px-2 pb-2">
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-calendar-days"
label="Heute"
@click="() => { setDateFieldToToday('date'); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
/>
</div>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Fälligkeitsdatum">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.dueDate)"
:disabled="mode === 'show'"
:color="itemInfo.dueDate ? 'neutral' : 'error'"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.dueDate)"
@update:model-value="(value) => setDateField('dueDate', value)"
:week-starts-on="1"
/>
<div class="flex justify-end px-2 pb-2">
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-calendar-days"
label="Heute"
@click="setDateFieldToToday('dueDate')"
/>
</div>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField>
</div>
</UCard>
<div v-if="mode === 'show'" class="space-y-4">
<div class="flex justify-between items-end border-b pb-2 dark:border-gray-700">
<h3 class="font-semibold text-lg">Positionen</h3>
</div>
<div
v-for="(item, index) in itemInfo.accounts"
:key="`show-${index}`"
class="rounded-md border border-gray-200 bg-white p-4 text-sm dark:border-gray-800 dark:bg-gray-900"
>
<div class="mb-3 flex items-start justify-between gap-4">
<div>
<p class="text-xs text-gray-500">Position {{ index + 1 }}</p>
<p class="font-medium">{{ item.description || "Ohne Positionstext" }}</p>
</div>
<UBadge variant="soft" color="neutral">{{ getBookingModeLabel(item) }}</UBadge>
</div>
<dl class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<dt class="text-gray-500">Konto / Kategorie</dt>
<dd class="font-medium">{{ getAccountLabel(item) }}</dd>
</div>
<div>
<dt class="text-gray-500">Kostenstelle</dt>
<dd class="font-medium">{{ getCostCentreLabel(item) }}</dd>
</div>
<div>
<dt class="text-gray-500">Steuerschlüssel</dt>
<dd class="font-medium">{{ getTaxLabel(item) }}</dd>
</div>
<div>
<dt class="text-gray-500">Netto</dt>
<dd class="font-medium">{{ formatMoney(item.amountNet) }}</dd>
</div>
<div>
<dt class="text-gray-500">Steuerbetrag</dt>
<dd class="font-medium">{{ formatMoney(item.amountTax) }}</dd>
</div>
<div>
<dt class="text-gray-500">Brutto</dt>
<dd class="font-medium">{{ formatMoney(item.amountGross) }}</dd>
</div>
</dl>
</div>
</div>
<div v-if="mode !== 'show'" class="space-y-4">
<div class="flex justify-between items-end border-b pb-2 dark:border-gray-700">
<h3 class="font-semibold text-lg">Positionen</h3>
<div class="flex items-center gap-2 text-sm">
<span :class="{'font-bold': !useNetMode, 'opacity-50': useNetMode}">Brutto</span>
<USwitch v-model="useNetMode" color="primary" :disabled="mode === 'show'" />
<span :class="{'font-bold': useNetMode, 'opacity-50': !useNetMode}">Netto Eingabe</span>
</div>
</div>
<UCard
v-for="(item, index) in itemInfo.accounts"
:key="index"
:ui="{ body: { padding: 'p-4 sm:p-4' } }"
class="relative border-l-4"
:class="item.amountNet ? 'border-l-primary-500' : 'border-l-gray-300 dark:border-l-gray-700'"
>
<UButton
v-if="itemInfo.accounts.length > 1 && mode !== 'show'"
icon="i-heroicons-trash"
color="error"
variant="ghost"
size="xs"
class="absolute top-2 right-2"
@click="itemInfo.accounts.splice(index, 1)"
/>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-6">
<UFormField label="Konto / Kategorie">
<USelectMenu
class="w-full"
v-model="item.account"
:items="accounts"
:search-input="{ placeholder: 'Kategorie wählen' }"
label-key="label"
value-key="id"
:disabled="mode === 'show'"
:filter-fields="['label', 'number']"
:color="item.account ? 'primary' : 'error'"
>
<template #item="{ item: option }">
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
</template>
<template #default>
{{ accounts.find(a => a.id === item.account)?.label || 'Auswählen' }}
</template>
</USelectMenu>
</UFormField>
</div>
<div class="col-span-12 md:col-span-6">
<UFormField label="Aufwandsart">
<USelectMenu
class="w-full"
:items="EXPENSE_BOOKING_MODE_ITEMS"
value-key="value"
label-key="label"
v-model="item.bookingMode"
:disabled="mode === 'show'"
@update:model-value="updateBookingMode(item)"
/>
</UFormField>
</div>
<div class="col-span-12 md:col-span-6">
<UFormField label="Kostenstelle">
<USelectMenu
class="w-full"
v-model="item.costCentre"
:items="costcentres"
:search-input="{ placeholder: 'Optional' }"
label-key="name"
value-key="id"
:disabled="mode === 'show'"
>
<template #default>
{{ costcentres.find(c => c.id === item.costCentre)?.name || 'Keine' }}
</template>
</USelectMenu>
</UFormField>
</div>
<template v-if="isDepreciationItem(item)">
<div class="col-span-12 md:col-span-4">
<UFormField label="Abschreibungsdauer (Monate)">
<UInput
class="w-full"
type="number"
min="1"
step="1"
v-model="item.depreciationMonths"
:disabled="mode === 'show'"
/>
</UFormField>
</div>
<div class="col-span-12 md:col-span-4">
<UFormField label="Methode">
<USelectMenu
class="w-full"
:items="DEPRECIATION_METHOD_ITEMS"
value-key="value"
label-key="label"
v-model="item.depreciationMethod"
:disabled="mode === 'show'"
/>
</UFormField>
</div>
<div class="col-span-12 md:col-span-4">
<UFormField label="Start Abschreibung">
<UInput
class="w-full"
type="date"
v-model="item.depreciationStartDate"
:disabled="mode === 'show'"
/>
</UFormField>
</div>
<div class="col-span-12 md:col-span-4">
<UFormField :label="item.bookingMode === 'depreciation_bundle' ? 'Sammelposten' : 'Bezeichnung Abschreibung'">
<UInput
class="w-full"
v-model="item[item.bookingMode === 'depreciation_bundle' ? 'depreciationGroup' : 'depreciationLabel']"
:disabled="mode === 'show'"
:placeholder="item.bookingMode === 'depreciation_bundle' ? 'z. B. IT-Hardware 2026' : 'z. B. Notebook Fuhrpark' "
/>
</UFormField>
</div>
<div class="col-span-12">
<UAlert
color="primary"
variant="soft"
icon="i-heroicons-calendar-days"
title="Nur die monatliche Abschreibung erscheint in der BWA"
:description="item.bookingMode === 'depreciation_bundle'
? 'Diese Position wird nicht sofort als Aufwand gezählt, sondern als Sammelposten periodisiert.'
: 'Diese Position wird nicht sofort als Aufwand gezählt, sondern über die gewählte Laufzeit abgeschrieben.'"
/>
</div>
</template>
<div class="col-span-12 md:col-span-3">
<UFormField label="Betrag (Netto)">
<UInput
class="w-full"
type="number"
step="0.01"
:disabled="mode === 'show' || !useNetMode"
:model-value="item.amountNet"
@update:model-value="(val) => { item.amountNet = Number(val); recalculateItem(item, 'net') }"
>
<template #trailing>€</template>
</UInput>
</UFormField>
</div>
<div class="col-span-12 md:col-span-3">
<UFormField label="Betrag (Brutto)">
<UInput
class="w-full"
type="number"
step="0.01"
:disabled="mode === 'show' || useNetMode"
:model-value="item.amountGross"
@update:model-value="(val) => { item.amountGross = Number(val); recalculateItem(item, 'gross') }"
>
<template #trailing>€</template>
</UInput>
</UFormField>
</div>
<div class="col-span-6 md:col-span-3">
<UFormField label="Steuerschlüssel">
<USelectMenu
class="w-full"
v-model="item.taxType"
:items="taxOptions"
value-key="key"
label-key="label"
:disabled="mode === 'show'"
@update:model-value="recalculateItem(item, 'taxType')"
:color="item.taxType ? 'primary' : 'error'"
/>
</UFormField>
</div>
<div class="col-span-6 md:col-span-3">
<UFormField label="Steuerbetrag" help="Automatisch berechnet">
<UInput class="w-full" :model-value="item.amountTax" disabled color="gray" >
<template #trailing>€</template>
</UInput>
</UFormField>
</div>
<div class="col-span-12 flex justify-end gap-2">
<UButton
size="xs"
variant="outline"
icon="i-heroicons-calculator"
:disabled="mode === 'show'"
@click="recalculateItem(item, 'manual')"
>
Steuer berechnen
</UButton>
<UButton
size="xs"
variant="soft"
icon="i-heroicons-arrow-right"
:disabled="mode === 'show' || !hasAmount(item.amountGross)"
@click="moveGrossToNet(item)"
>
Brutto als Netto
</UButton>
</div>
<div class="col-span-12">
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="w-full border-b border-gray-100 dark:border-gray-800" />
</div>
</div>
</UCard>
<UButton
v-if="mode !== 'show'"
icon="i-heroicons-plus"
variant="soft"
block
@click="itemInfo.accounts.push(createIncomingInvoiceAccount({ depreciationStartDate: itemInfo.date || null }))"
>
Weitere Position hinzufügen
</UButton>
</div>
<div class="mt-8 border-t pt-4 dark:border-gray-700">
<div class="flex justify-end">
<div class="w-full md:w-1/2 space-y-2 text-sm">
<div class="flex justify-between text-gray-500">
<span>Netto Gesamt</span>
<span>{{ totalCalculated.totalNet.toFixed(2) }} €</span>
</div>
<div class="flex justify-between text-gray-500" v-if="totalCalculated.totalAmount7Tax > 0">
<span>+ 7% USt</span>
<span>{{ totalCalculated.totalAmount7Tax.toFixed(2) }} €</span>
</div>
<div class="flex justify-between text-gray-500" v-if="totalCalculated.totalAmount19Tax > 0">
<span>+ 19% USt</span>
<span>{{ totalCalculated.totalAmount19Tax.toFixed(2) }} €</span>
</div>
<div class="flex justify-between font-bold text-xl text-gray-900 dark:text-white pt-2 border-t dark:border-gray-700">
<span>Rechnungsbetrag</span>
<span>{{ totalCalculated.totalGross.toFixed(2) }} €</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UDashboardPanelContent>
<PageLeaveGuard :when="mode !== 'show' && isModified"/>
<div
v-if="isPdfDetached && loadedFileId"
ref="pdfEl"
:style="pdfStyle"
class="fixed z-[999] w-[600px] h-[750px] bg-white dark:bg-gray-900 shadow-2xl rounded-xl border border-gray-200 dark:border-gray-800 flex flex-col resize overflow-hidden"
>
<div class="flex items-center justify-between p-2 cursor-move border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 select-none">
<div class="flex items-center gap-2 text-gray-500 px-2">
<UIcon name="i-heroicons-paper-clip" />
<span class="text-xs font-bold uppercase tracking-wider">Dokumentenansicht</span>
</div>
<div class="flex items-center gap-1">
<UTooltip text="Wieder andocken">
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-arrows-pointing-in"
size="xs"
@click="isPdfDetached = false"
/>
</UTooltip>
</div>
</div>
<div class="flex-grow relative bg-gray-200 dark:bg-gray-900 overflow-hidden">
<PDFViewer
:file-id="loadedFileId"
location="draggable_window"
class="w-full h-full"
/>
</div>
<div class="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="w-3 h-3 text-gray-400 transform rotate-90" />
</div>
</div>
</template>
<style scoped>
.resize {
resize: both;
}
</style>