Files
FEDEO/frontend/pages/accounting/depreciation.vue
2026-04-08 18:52:16 +02:00

430 lines
18 KiB
Vue

<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 periodStart = ref(dayjs().startOf("month").format("YYYY-MM-DD"))
const periodEnd = ref(dayjs().endOf("month").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 normalizedPeriod = computed(() => {
const start = dayjs(periodStart.value)
const end = dayjs(periodEnd.value)
if (start.isValid() && end.isValid() && start.isAfter(end, "day")) {
return {
start: end.startOf("day"),
end: start.endOf("day"),
}
}
return {
start: start.isValid() ? start.startOf("day") : dayjs().startOf("month"),
end: end.isValid() ? end.endOf("day") : dayjs().endOf("month"),
}
})
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,
}, normalizedPeriod.value.end)
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, normalizedPeriod.value.start, normalizedPeriod.value.end)
.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,
}, normalizedPeriod.value.end)
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, normalizedPeriod.value.start, normalizedPeriod.value.end)
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="periodStart" type="date" class="w-44" />
<UInput v-model="periodEnd" 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 {{ normalizedPeriod.end.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">Abschreibung im Zeitraum</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">{{ normalizedPeriod.start.format("DD.MM.YYYY") }} - {{ normalizedPeriod.end.format("DD.MM.YYYY") }}</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">Im 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>