Added Abschreibungen
This commit is contained in:
411
frontend/pages/accounting/depreciation.vue
Normal file
411
frontend/pages/accounting/depreciation.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
DEPRECIATION_METHOD_ITEMS,
|
||||
ensureDepreciationDefaults,
|
||||
getAssetDepreciationStatus,
|
||||
getIncomingInvoiceDepreciationRows,
|
||||
getStatementAllocationDepreciationRow,
|
||||
isDepreciationBookingMode,
|
||||
normalizeIncomingInvoiceAccount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const incomingInvoices = ref<any[]>([])
|
||||
const statementAllocations = ref<any[]>([])
|
||||
const asOfDate = ref(dayjs().format("YYYY-MM-DD"))
|
||||
const selectedAsset = ref<any | null>(null)
|
||||
const editState = ref<any | null>(null)
|
||||
const editOpen = computed({
|
||||
get: () => !!selectedAsset.value,
|
||||
set: (value: boolean) => {
|
||||
if (!value) closeEdit()
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
}).format(Number(value || 0))
|
||||
|
||||
const isRelevantInputInvoice = (invoice: any) => invoice?.state === "Gebucht" && !!invoice?.date
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [invoices, allocations] = await Promise.all([
|
||||
useEntities("incominginvoices").select("*, vendor(*)"),
|
||||
useEntities("statementallocations").select("*, bankstatement(*), vendor(*), customer(*)")
|
||||
])
|
||||
|
||||
incomingInvoices.value = (invoices || []).filter(isRelevantInputInvoice)
|
||||
statementAllocations.value = (allocations || []).filter((item: any) => Number(item?.amount || 0) < 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const depreciationAssets = computed(() => {
|
||||
const invoiceAssets = incomingInvoices.value.flatMap((invoice: any) => {
|
||||
return (invoice.accounts || [])
|
||||
.map((account: any, index: number) => {
|
||||
const normalized = ensureDepreciationDefaults(normalizeIncomingInvoiceAccount(account, invoice.date), invoice.date)
|
||||
if (!isDepreciationBookingMode(normalized.bookingMode)) return null
|
||||
|
||||
const status = getAssetDepreciationStatus({
|
||||
amountNet: Number(normalized.amountNet || 0),
|
||||
depreciationMonths: normalized.depreciationMonths,
|
||||
depreciationStartDate: normalized.depreciationStartDate || invoice.date,
|
||||
depreciationMethod: normalized.depreciationMethod,
|
||||
residualValue: normalized.residualValue,
|
||||
}, asOfDate.value)
|
||||
|
||||
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||
.find((row: any) => row.index === index)
|
||||
|
||||
return {
|
||||
key: `invoice-${invoice.id}-${index}`,
|
||||
sourceType: "incominginvoice",
|
||||
sourceId: invoice.id,
|
||||
accountIndex: index,
|
||||
label: normalized.depreciationLabel || normalized.description || invoice.reference || `Eingangsbeleg ${invoice.id}`,
|
||||
group: normalized.depreciationGroup || null,
|
||||
mode: normalized.bookingMode,
|
||||
method: normalized.depreciationMethod,
|
||||
months: Number(normalized.depreciationMonths || 0),
|
||||
startDate: normalized.depreciationStartDate || invoice.date,
|
||||
residualValue: Number(normalized.residualValue || 0),
|
||||
originalValue: Number(normalized.amountNet || 0),
|
||||
currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
|
||||
depreciated: status.depreciated,
|
||||
remaining: status.remaining,
|
||||
progressPercent: status.progressPercent,
|
||||
vendorName: invoice.vendor?.name || "-",
|
||||
reference: invoice.reference || "-",
|
||||
sourceRecord: invoice,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const allocationAssets = statementAllocations.value
|
||||
.map((allocation: any) => {
|
||||
if (!isDepreciationBookingMode(allocation?.bookingMode)) return null
|
||||
|
||||
const status = getAssetDepreciationStatus({
|
||||
amount: Math.abs(Number(allocation.amount || 0)),
|
||||
depreciationMonths: allocation.depreciationMonths,
|
||||
depreciationStartDate: allocation.depreciationStartDate || allocation.created_at,
|
||||
depreciationMethod: allocation.depreciationMethod,
|
||||
residualValue: allocation.residualValue,
|
||||
}, asOfDate.value)
|
||||
|
||||
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||
|
||||
return {
|
||||
key: `allocation-${allocation.id}`,
|
||||
sourceType: "statementallocation",
|
||||
sourceId: allocation.id,
|
||||
accountIndex: null,
|
||||
label: allocation.depreciationLabel || allocation.description || "Direkte Abschreibung",
|
||||
group: allocation.depreciationGroup || null,
|
||||
mode: allocation.bookingMode,
|
||||
method: allocation.depreciationMethod || "linear",
|
||||
months: Number(allocation.depreciationMonths || 0),
|
||||
startDate: allocation.depreciationStartDate || allocation.created_at,
|
||||
residualValue: Number(allocation.residualValue || 0),
|
||||
originalValue: Math.abs(Number(allocation.amount || 0)),
|
||||
currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
|
||||
depreciated: status.depreciated,
|
||||
remaining: status.remaining,
|
||||
progressPercent: status.progressPercent,
|
||||
vendorName: allocation.vendor?.name || allocation.customer?.name || "-",
|
||||
reference: allocation.description || "-",
|
||||
sourceRecord: allocation,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return [...invoiceAssets, ...allocationAssets]
|
||||
.sort((left: any, right: any) => Number(right.originalValue) - Number(left.originalValue))
|
||||
})
|
||||
|
||||
const groupedAssets = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
|
||||
depreciationAssets.value.forEach((asset: any) => {
|
||||
const groupKey = asset.group || asset.key
|
||||
const current = groups.get(groupKey) || {
|
||||
key: groupKey,
|
||||
label: asset.group || asset.label,
|
||||
isBundle: !!asset.group,
|
||||
assets: [],
|
||||
originalValue: 0,
|
||||
depreciated: 0,
|
||||
remaining: 0,
|
||||
currentPeriodAmount: 0,
|
||||
}
|
||||
|
||||
current.assets.push(asset)
|
||||
current.originalValue += asset.originalValue
|
||||
current.depreciated += asset.depreciated
|
||||
current.remaining += asset.remaining
|
||||
current.currentPeriodAmount += asset.currentPeriodAmount
|
||||
groups.set(groupKey, current)
|
||||
})
|
||||
|
||||
return Array.from(groups.values()).map((group: any) => ({
|
||||
...group,
|
||||
originalValue: Number(group.originalValue.toFixed(2)),
|
||||
depreciated: Number(group.depreciated.toFixed(2)),
|
||||
remaining: Number(group.remaining.toFixed(2)),
|
||||
currentPeriodAmount: Number(group.currentPeriodAmount.toFixed(2)),
|
||||
residualValue: Number(group.assets.reduce((sum: number, asset: any) => sum + Number(asset.residualValue || 0), 0).toFixed(2)),
|
||||
progressPercent: getProgressPercent(
|
||||
group.depreciated,
|
||||
group.originalValue,
|
||||
group.assets.reduce((sum: number, asset: any) => sum + Number(asset.residualValue || 0), 0)
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
const totals = computed(() => {
|
||||
return groupedAssets.value.reduce((sum: any, group: any) => ({
|
||||
originalValue: Number((sum.originalValue + group.originalValue).toFixed(2)),
|
||||
depreciated: Number((sum.depreciated + group.depreciated).toFixed(2)),
|
||||
remaining: Number((sum.remaining + group.remaining).toFixed(2)),
|
||||
currentPeriodAmount: Number((sum.currentPeriodAmount + group.currentPeriodAmount).toFixed(2)),
|
||||
count: sum.count + group.assets.length,
|
||||
bundleCount: sum.bundleCount + (group.isBundle ? 1 : 0),
|
||||
}), { originalValue: 0, depreciated: 0, remaining: 0, currentPeriodAmount: 0, count: 0, bundleCount: 0 })
|
||||
})
|
||||
|
||||
const getProgressPercent = (depreciated: number, originalValue: number, residualValue: number) => {
|
||||
const depreciableBase = Math.max(0, Number(originalValue || 0) - Number(residualValue || 0))
|
||||
if (!depreciableBase) return 0
|
||||
return Math.min(100, Number(((Number(depreciated || 0) / depreciableBase) * 100).toFixed(2)))
|
||||
}
|
||||
|
||||
const startEdit = (asset: any) => {
|
||||
selectedAsset.value = asset
|
||||
editState.value = {
|
||||
depreciationLabel: asset.label,
|
||||
depreciationGroup: asset.group || "",
|
||||
depreciationMethod: asset.method || "linear",
|
||||
depreciationMonths: asset.months || 36,
|
||||
depreciationStartDate: dayjs(asset.startDate).format("YYYY-MM-DD"),
|
||||
residualValue: asset.residualValue || 0,
|
||||
}
|
||||
}
|
||||
|
||||
const closeEdit = () => {
|
||||
selectedAsset.value = null
|
||||
editState.value = null
|
||||
}
|
||||
|
||||
const saveAsset = async () => {
|
||||
if (!selectedAsset.value || !editState.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (selectedAsset.value.sourceType === "incominginvoice") {
|
||||
const source = selectedAsset.value.sourceRecord
|
||||
const nextAccounts = [...(source.accounts || [])]
|
||||
nextAccounts[selectedAsset.value.accountIndex] = {
|
||||
...nextAccounts[selectedAsset.value.accountIndex],
|
||||
depreciationLabel: editState.value.depreciationLabel,
|
||||
depreciationGroup: selectedAsset.value.mode === "depreciation_bundle" ? editState.value.depreciationGroup : "",
|
||||
depreciationMethod: editState.value.depreciationMethod,
|
||||
depreciationMonths: Number(editState.value.depreciationMonths || 0),
|
||||
depreciationStartDate: editState.value.depreciationStartDate,
|
||||
residualValue: Number(editState.value.residualValue || 0),
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...source,
|
||||
vendor: source.vendor?.id || source.vendor,
|
||||
accounts: nextAccounts,
|
||||
}
|
||||
delete payload.statementallocations
|
||||
delete payload.files
|
||||
await useEntities("incominginvoices").update(source.id, payload, true)
|
||||
} else {
|
||||
const source = selectedAsset.value.sourceRecord
|
||||
await useEntities("statementallocations").update(source.id, {
|
||||
depreciationLabel: editState.value.depreciationLabel,
|
||||
depreciationGroup: selectedAsset.value.mode === "depreciation_bundle" ? editState.value.depreciationGroup : null,
|
||||
depreciationMethod: editState.value.depreciationMethod,
|
||||
depreciationMonths: Number(editState.value.depreciationMonths || 0),
|
||||
depreciationStartDate: editState.value.depreciationStartDate,
|
||||
residualValue: Number(editState.value.residualValue || 0),
|
||||
}, true)
|
||||
}
|
||||
|
||||
toast.add({ title: "Abschreibung gespeichert", color: "success" })
|
||||
closeEdit()
|
||||
await loadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Abschreibungen">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput v-model="asOfDate" type="date" class="w-44" />
|
||||
<UButton icon="i-heroicons-arrow-path" variant="outline" :loading="loading" @click="loadData">
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="space-y-6 p-4 md:p-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Anschaffungswert</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ formatCurrency(totals.originalValue) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ totals.count }} Abschreibungspositionen</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Bereits abgeschrieben</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(totals.depreciated) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Stand {{ dayjs(asOfDate).format("DD.MM.YYYY") }}</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Restbuchwert</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-amber-600 dark:text-amber-400">{{ formatCurrency(totals.remaining) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Nach Restwertlogik</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Aktuelle Abschreibung</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-error">{{ formatCurrency(totals.currentPeriodAmount) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ totals.bundleCount }} Sammelposten</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="group in groupedAssets" :key="group.key">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ group.label }}</span>
|
||||
<UBadge v-if="group.isBundle" color="warning" variant="soft">Sammelposten</UBadge>
|
||||
<UBadge color="neutral" variant="soft">{{ group.assets.length }} Positionen</UBadge>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatCurrency(group.depreciated) }} abgeschrieben | {{ formatCurrency(group.remaining) }} Restbuchwert
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-52 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Fortschritt</span>
|
||||
<span>{{ group.progressPercent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-500"
|
||||
:style="{ width: `${group.progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="asset in group.assets" :key="asset.key" class="rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ asset.label }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ asset.vendorName }} | {{ asset.reference }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<UBadge color="neutral" variant="soft">{{ asset.method === "degressive" ? "Degressiv" : "Linear" }}</UBadge>
|
||||
<UBadge color="neutral" variant="soft">{{ asset.months }} Monate</UBadge>
|
||||
<UBadge color="neutral" variant="soft">Start {{ dayjs(asset.startDate).format("MM/YYYY") }}</UBadge>
|
||||
<UBadge color="neutral" variant="soft">Restwert {{ formatCurrency(asset.residualValue) }}</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" variant="outline" icon="i-heroicons-pencil-square" @click="startEdit(asset)">
|
||||
Bearbeiten
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Anschaffungswert</div>
|
||||
<div class="mt-1 font-semibold">{{ formatCurrency(asset.originalValue) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Abgeschrieben</div>
|
||||
<div class="mt-1 font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(asset.depreciated) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Aktueller Zeitraum</div>
|
||||
<div class="mt-1 font-semibold text-error">{{ formatCurrency(asset.currentPeriodAmount) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Restbuchwert</div>
|
||||
<div class="mt-1 font-semibold text-amber-600 dark:text-amber-400">{{ formatCurrency(asset.remaining) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Wirklicher Abschreibungsfortschritt</span>
|
||||
<span>{{ getProgressPercent(asset.depreciated, asset.originalValue, asset.residualValue).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-500"
|
||||
:style="{ width: `${getProgressPercent(asset.depreciated, asset.originalValue, asset.residualValue)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<USlideover v-model:open="editOpen" title="Abschreibung bearbeiten">
|
||||
<template #body>
|
||||
<div v-if="selectedAsset && editState" class="space-y-4 p-4">
|
||||
<UFormField label="Bezeichnung">
|
||||
<UInput v-model="editState.depreciationLabel" />
|
||||
</UFormField>
|
||||
<UFormField v-if="selectedAsset.mode === 'depreciation_bundle'" label="Sammelposten">
|
||||
<UInput v-model="editState.depreciationGroup" />
|
||||
</UFormField>
|
||||
<UFormField label="Methode">
|
||||
<USelectMenu v-model="editState.depreciationMethod" :items="DEPRECIATION_METHOD_ITEMS" value-key="value" label-key="label" />
|
||||
</UFormField>
|
||||
<UFormField label="Dauer (Monate)">
|
||||
<UInput v-model="editState.depreciationMonths" type="number" min="1" step="1" />
|
||||
</UFormField>
|
||||
<UFormField label="Start Abschreibung">
|
||||
<UInput v-model="editState.depreciationStartDate" type="date" />
|
||||
</UFormField>
|
||||
<UFormField label="Restwert">
|
||||
<UInput v-model="editState.residualValue" type="number" min="0" step="0.01" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 p-4">
|
||||
<UButton variant="ghost" @click="closeEdit">Abbrechen</UButton>
|
||||
<UButton :loading="saving" @click="saveAsset">Speichern</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
Reference in New Issue
Block a user