Files
FEDEO/frontend/pages/incomingInvoices/[mode]/[id].vue
florianfederspiel af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
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>
2026-11-04 11:31:47 +01:00

1141 lines
48 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 hasActiveBankAssignmentIn = (allocations = []) => allocations.some((allocation) => {
if (allocation?.archived) return false
return Boolean(allocation?.bankstatement || allocation?.bs_id)
})
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)
}
if (mode.value === "edit" && invoiceData.state === "Gebucht" && (invoiceData.archived || hasActiveBankAssignmentIn(invoiceData.statementallocations))) {
toast.add({
title: "Bearbeiten nicht möglich",
description: invoiceData.archived
? "Archivierte Eingangsbelege können nicht bearbeitet werden."
: "Der Eingangsbeleg ist bereits einer Bankbuchung zugewiesen.",
color: "error"
})
await navigateTo(`/incomingInvoices/show/${invoiceData.id}`)
return
}
// 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 editingTotalTax = ref(false)
const totalTaxDraft = ref(null)
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 hasActiveBankAssignment = computed(() => hasActiveBankAssignmentIn(itemInfo.value.statementallocations))
const isBookedIncomingInvoice = computed(() => itemInfo.value.state === "Gebucht")
const canEditIncomingInvoice = computed(() => (
mode.value === "show"
&& isBookedIncomingInvoice.value
&& !itemInfo.value.archived
&& !hasActiveBankAssignment.value
))
const canArchiveIncomingInvoice = computed(() => {
if (itemInfo.value.archived) return false
if (isBookedIncomingInvoice.value) return !hasActiveBankAssignment.value
return mode.value !== "show"
})
const vendorName = computed(() => vendors.value.find((vendor) => vendor.id === itemInfo.value.vendor)?.name || "-")
const eInvoiceValidation = computed(() => itemInfo.value.eInvoiceValidation || null)
const eInvoiceSourceLabel = computed(() => {
if (itemInfo.value.preparationSource !== "e-invoice") return null
if (itemInfo.value.eInvoiceSyntax === "cii") {
return String(itemInfo.value.eInvoiceProfile || "").toLowerCase().includes("xrechnung")
? "XRechnung (CII)"
: "ZUGFeRD / Factur-X (CII)"
}
if (itemInfo.value.eInvoiceSyntax === "ubl-credit-note") return "XRechnung / UBL-Gutschrift"
return "XRechnung / UBL"
})
const eInvoiceProfileLabel = computed(() => itemInfo.value.eInvoiceProfile || "Profil nicht angegeben")
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 canEditCostCentres = computed(() => mode.value === "show" && Boolean(itemInfo.value.id) && !itemInfo.value.archived)
const costCentresOpen = ref(false)
const costCentresSaving = ref(false)
const costCentreDraft = ref([])
const costCentreOptionLabel = (id) => {
const centre = costcentres.value.find((item) => item.id === id)
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
}
const openCostCentres = () => {
costCentreDraft.value = (itemInfo.value.accounts || []).map((account, index) => ({
index,
label: `${index + 1}. ${account.description || getAccountLabel(account)}`,
costcentre: account.costCentre || null,
}))
costCentresOpen.value = true
}
const saveCostCentres = async () => {
if (costCentresSaving.value) return
costCentresSaving.value = true
try {
const accountCostCentres = costCentreDraft.value
.filter((row) => row.costcentre !== (itemInfo.value.accounts[row.index]?.costCentre || null))
.map(({ index, costcentre }) => ({ index, costcentre }))
if (accountCostCentres.length) {
const { invoice } = await useNuxtApp().$api(`/api/functions/incoming-invoices/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: { accountCostCentres },
})
itemInfo.value.accounts = normalizeIncomingInvoiceAccounts(invoice.accounts || [], invoice.date)
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
}
costCentresOpen.value = false
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
} finally {
costCentresSaving.value = false
}
}
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)
}
})
const calculatedTax = Number(totalAmount19Tax + totalAmount7Tax)
const hasTaxOverride = itemInfo.value.taxAmountOverride !== null && itemInfo.value.taxAmountOverride !== undefined && itemInfo.value.taxAmountOverride !== ""
const totalTax = hasTaxOverride ? Number(itemInfo.value.taxAmountOverride) : calculatedTax
totalGross = Number(totalNet + totalTax)
return { totalNet, totalAmount19Tax, totalAmount7Tax, calculatedTax, totalTax, totalGross, hasTaxOverride }
})
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')
}
const startEditingTotalTax = () => {
totalTaxDraft.value = totalCalculated.value.totalTax.toFixed(2)
editingTotalTax.value = true
}
const applyTotalTaxOverride = () => {
if (!hasValidNumber(totalTaxDraft.value)) return
const value = Number(Number(totalTaxDraft.value).toFixed(2))
itemInfo.value.taxAmountOverride = Math.abs(value - totalCalculated.value.calculatedTax) >= 0.005 ? value : null
editingTotalTax.value = false
}
// --- Saving ---
const updateIncomingInvoice = async (setBooked = false) => {
const keepBooked = setBooked || isBookedIncomingInvoice.value
if (keepBooked && 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 = keepBooked ? "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>
<UButton
v-if="canEditIncomingInvoice"
icon="i-heroicons-pencil-square"
variant="outline"
@click="navigateTo(`/incomingInvoices/edit/${route.params.id}`)"
>
Bearbeiten
</UButton>
<UButton
v-if="canEditCostCentres"
icon="i-heroicons-tag"
variant="outline"
@click="openCostCentres"
>
Kostenstellen bearbeiten
</UButton>
<ArchiveButton
v-if="canArchiveIncomingInvoice"
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' && !isBookedIncomingInvoice"
@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="eInvoiceSourceLabel"
:title="eInvoiceSourceLabel"
color="primary"
variant="soft"
icon="i-heroicons-document-check"
>
<template #description>
<div class="mt-1 space-y-2 text-sm">
<p class="break-all">{{ eInvoiceProfileLabel }}</p>
<div v-if="eInvoiceValidation?.errors?.length">
<p class="font-semibold">Validierungsfehler</p>
<ul class="list-inside list-disc">
<li v-for="error in eInvoiceValidation.errors" :key="error">{{ error }}</li>
</ul>
</div>
<div v-if="eInvoiceValidation?.warnings?.length">
<p class="font-semibold">Hinweise</p>
<ul class="list-inside list-disc">
<li v-for="warning in eInvoiceValidation.warnings" :key="warning">{{ warning }}</li>
</ul>
</div>
<p v-if="!eInvoiceValidation?.errors?.length && !eInvoiceValidation?.warnings?.length">
Pflichtfelder und Rechnungssummen wurden erfolgreich geprüft.
</p>
</div>
</template>
</UAlert>
<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>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</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 items-center justify-between gap-3 text-gray-500">
<span class="flex items-center gap-2">
USt gesamt
<UBadge v-if="totalCalculated.hasTaxOverride" size="xs" color="warning" variant="soft">Manuell</UBadge>
</span>
<div v-if="editingTotalTax" class="flex items-center gap-1">
<UInput v-model="totalTaxDraft" type="number" step="0.01" size="xs" class="w-28">
<template #trailing>€</template>
</UInput>
<UButton icon="i-heroicons-check" size="xs" variant="ghost" @click="applyTotalTaxOverride" />
<UButton icon="i-heroicons-x-mark" size="xs" color="neutral" variant="ghost" @click="editingTotalTax = false" />
</div>
<div v-else class="flex items-center gap-1">
<span>{{ totalCalculated.totalTax.toFixed(2) }} €</span>
<UButton
v-if="mode !== 'show'"
icon="i-heroicons-pencil"
size="xs"
color="neutral"
variant="ghost"
aria-label="Gesamten USt-Betrag bearbeiten"
@click="startEditingTotalTax"
/>
</div>
</div>
<p v-if="totalCalculated.hasTaxOverride" class="text-xs text-amber-600 dark:text-amber-400">
DATEV berechnet die USt aus dem Bruttobetrag erneut. Der Export wird mit „USt prüfen“ markiert.
</p>
<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>
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
<template #body>
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
<div class="space-y-4">
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
<InputGroup>
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
</InputGroup>
</UFormField>
</div>
</template>
<template #footer>
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
</template>
</UModal>
</template>
<style scoped>
.resize {
resize: both;
}
</style>