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>
1141 lines
48 KiB
Vue
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>
|