430 lines
18 KiB
Vue
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>
|