Files
FEDEO/frontend/components/displayIncomeAndExpenditure.vue
2026-02-22 19:33:56 +01:00

282 lines
7.9 KiB
Vue

<script setup>
import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat)
const tempStore = useTempStore()
const amountMode = ref("net")
const granularity = ref("year")
const selectedYear = ref(dayjs().year())
const selectedMonth = ref(dayjs().month() + 1)
const incomeDocuments = ref([])
const expenseInvoices = ref([])
const granularityOptions = [
{ label: "Jahr", value: "year" },
{ label: "Monat", value: "month" }
]
const monthOptions = [
{ label: "Januar", value: 1 },
{ label: "Februar", value: 2 },
{ label: "März", value: 3 },
{ label: "April", value: 4 },
{ label: "Mai", value: 5 },
{ label: "Juni", value: 6 },
{ label: "Juli", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "Oktober", value: 10 },
{ label: "November", value: 11 },
{ label: "Dezember", value: 12 }
]
const normalizeMode = (value) => value === "gross" ? "gross" : "net"
const normalizeGranularity = (value) => value === "month" ? "month" : "year"
watch(
() => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
granularity.value = normalizeGranularity(storedView?.granularity)
const nextYear = Number(storedView?.year)
const nextMonth = Number(storedView?.month)
selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
? nextMonth
: dayjs().month() + 1
},
{ immediate: true }
)
watch([amountMode, granularity, selectedYear, selectedMonth], () => {
tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
})
// Backward compatibility for any existing consumers.
tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.date)
}
const yearsInData = computed(() => {
const years = new Set([dayjs().year()])
incomeDocuments.value.forEach((item) => {
const parsed = dayjs(item.documentDate)
if (parsed.isValid()) years.add(parsed.year())
})
expenseInvoices.value.forEach((item) => {
const parsed = dayjs(item.date)
if (parsed.isValid()) years.add(parsed.year())
})
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => yearsInData.value.map((year) => ({ label: String(year), value: year })))
watch(yearsInData, (years) => {
if (!years.includes(selectedYear.value) && years.length > 0) {
selectedYear.value = years[0]
}
}, { immediate: true })
const computeDocumentAmount = (doc) => {
let amount = 0
;(doc.rows || []).forEach((row) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const net = Number(row.price || 0) * Number(row.quantity || 0) * (1 - Number(row.discountPercent || 0) / 100)
const taxPercent = Number(row.taxPercent)
const gross = net * (1 + (Number.isFinite(taxPercent) ? taxPercent : 0) / 100)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const computeIncomingInvoiceAmount = (invoice) => {
let amount = 0
;(invoice.accounts || []).forEach((account) => {
const net = Number(account.amountNet || 0)
const tax = Number(account.amountTax || 0)
const grossValue = Number(account.amountGross)
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const buckets = computed(() => {
const income = {}
const expense = {}
if (granularity.value === "year") {
for (let month = 1; month <= 12; month += 1) {
const key = String(month).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
} else {
const daysInMonth = dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).daysInMonth()
for (let day = 1; day <= daysInMonth; day += 1) {
const key = String(day).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
}
incomeDocuments.value.forEach((doc) => {
const docDate = dayjs(doc.documentDate)
if (!docDate.isValid() || docDate.year() !== selectedYear.value) return
if (granularity.value === "month" && docDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(docDate.month() + 1).padStart(2, "0")
: String(docDate.date()).padStart(2, "0")
income[key] = Number((income[key] + computeDocumentAmount(doc)).toFixed(2))
})
expenseInvoices.value.forEach((invoice) => {
const invoiceDate = dayjs(invoice.date)
if (!invoiceDate.isValid() || invoiceDate.year() !== selectedYear.value) return
if (granularity.value === "month" && invoiceDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(invoiceDate.month() + 1).padStart(2, "0")
: String(invoiceDate.date()).padStart(2, "0")
expense[key] = Number((expense[key] + computeIncomingInvoiceAmount(invoice)).toFixed(2))
})
return { income, expense }
})
const chartLabels = computed(() => {
if (granularity.value === "year") {
return ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
}
return Object.keys(buckets.value.income).map((day) => `${day}.`)
})
const chartData = computed(() => {
const keys = Object.keys(buckets.value.income).sort()
return {
labels: chartLabels.value,
datasets: [
{
label: "Ausgaben",
backgroundColor: "#f87979",
borderColor: "#f87979",
data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3,
},
{
label: "Einnahmen",
backgroundColor: "#69c350",
borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3
},
],
}
})
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
})
loadData()
</script>
<template>
<div class="h-full flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<USelectMenu
v-model="granularity"
:options="granularityOptions"
value-attribute="value"
option-attribute="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:options="yearOptions"
value-attribute="value"
option-attribute="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:options="monthOptions"
value-attribute="value"
option-attribute="label"
class="w-36"
/>
</div>
<UButtonGroup size="xs">
<UButton
:variant="amountMode === 'net' ? 'solid' : 'outline'"
@click="amountMode = 'net'"
>
Netto
</UButton>
<UButton
:variant="amountMode === 'gross' ? 'solid' : 'outline'"
@click="amountMode = 'gross'"
>
Brutto
</UButton>
</UButtonGroup>
</div>
<div class="flex-1 min-h-[280px]">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>
<style scoped>
</style>