feat: dashboard page with year/month summary, charts and CSV upload
This commit is contained in:
parent
19aca0e5fc
commit
b1fbc921cd
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CumulativeSpendingDto } from '../api/apiClient'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CumulativeSpendingDto[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
chart: { type: 'line', background: 'transparent', toolbar: { show: false } },
|
||||
xaxis: { categories: props.data.map(d => `${d.day}.`) },
|
||||
theme: { mode: 'dark' },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: {
|
||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
||||
},
|
||||
colors: ['#ef5350'],
|
||||
}))
|
||||
|
||||
const series = computed(() => [{
|
||||
name: 'Kumulativní výdaje',
|
||||
data: props.data.map(d => d.cumulativeSpent ?? 0)
|
||||
}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
||||
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">Žádné výdaje</div>
|
||||
<apexchart v-else type="line" :options="chartOptions" :series="series" height="220" />
|
||||
</template>
|
||||
59
src/AccountTracking.Web/src/components/DonutChart.vue
Normal file
59
src/AccountTracking.Web/src/components/DonutChart.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CategorySpendingDto } from '../api/apiClient'
|
||||
|
||||
const props = defineProps<{
|
||||
data: CategorySpendingDto[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
chart: { type: 'donut', background: 'transparent' },
|
||||
labels: props.data.map(d => d.category ?? 'Nezatříděno'),
|
||||
theme: { mode: 'dark' },
|
||||
legend: { show: false },
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: {
|
||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
||||
}
|
||||
}))
|
||||
|
||||
const series = computed(() => props.data.map(d => d.total ?? 0))
|
||||
|
||||
const legendItems = computed(() =>
|
||||
props.data.map((d, i) => ({
|
||||
label: d.category ?? 'Nezatříděno',
|
||||
total: d.total ?? 0,
|
||||
color: getColor(i),
|
||||
}))
|
||||
)
|
||||
|
||||
function getColor(i: number) {
|
||||
const colors = ['#ef5350','#42a5f5','#66bb6a','#ffa726','#ab47bc',
|
||||
'#26c6da','#d4e157','#ff7043','#8d6e63','#78909c']
|
||||
return colors[i % colors.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading">
|
||||
<v-skeleton-loader type="image" />
|
||||
</div>
|
||||
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">
|
||||
Žádné výdaje
|
||||
</div>
|
||||
<div v-else>
|
||||
<apexchart type="donut" :options="chartOptions" :series="series" height="200" />
|
||||
<div class="mt-2">
|
||||
<div
|
||||
v-for="item in legendItems"
|
||||
:key="item.label"
|
||||
class="d-flex align-center gap-2 mb-1"
|
||||
>
|
||||
<div :style="{ width: '10px', height: '10px', borderRadius: '50%', background: item.color, flexShrink: 0 }" />
|
||||
<span class="text-body-2 flex-grow-1">{{ item.label }}</span>
|
||||
<span class="text-body-2 text-medium-emphasis">{{ item.total.toLocaleString('cs-CZ') }} Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
74
src/AccountTracking.Web/src/components/MonthSummary.vue
Normal file
74
src/AccountTracking.Web/src/components/MonthSummary.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useDashPeriodStore } from '../stores/dashPeriod'
|
||||
import { dashboardApi } from '../api'
|
||||
import type { CategorySpendingDto, CumulativeSpendingDto, SummaryDto } from '../api/apiClient'
|
||||
import DonutChart from './DonutChart.vue'
|
||||
import CumulativeSpendingChart from './CumulativeSpendingChart.vue'
|
||||
|
||||
const period = useDashPeriodStore()
|
||||
|
||||
const summary = ref<SummaryDto | null>(null)
|
||||
const categories = ref<CategorySpendingDto[]>([])
|
||||
const cumulative = ref<CumulativeSpendingDto[]>([])
|
||||
const loadingSummary = ref(false)
|
||||
const loadingCategories = ref(false)
|
||||
const loadingCumulative = ref(false)
|
||||
|
||||
async function load() {
|
||||
loadingSummary.value = true
|
||||
loadingCategories.value = true
|
||||
loadingCumulative.value = true
|
||||
try {
|
||||
const [s, c, cu] = await Promise.all([
|
||||
dashboardApi.summary(period.year, period.month),
|
||||
dashboardApi.spendingByCategory(period.year, period.month),
|
||||
dashboardApi.cumulativeSpending(period.year, period.month),
|
||||
])
|
||||
summary.value = s
|
||||
categories.value = c
|
||||
cumulative.value = cu
|
||||
} finally {
|
||||
loadingSummary.value = false
|
||||
loadingCategories.value = false
|
||||
loadingCumulative.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => period.year, () => period.month], load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card height="100%">
|
||||
<v-card-title class="d-flex align-center pa-3">
|
||||
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevMonth" />
|
||||
<span class="flex-grow-1 text-center text-h6">{{ period.monthLabel }}</span>
|
||||
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextMonth" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
||||
<v-row v-else class="mb-4">
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
||||
<div class="text-h6 text-error">{{ (summary?.totalSpent ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
||||
<div class="text-h6 text-success">{{ (summary?.totalIncome ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="5">
|
||||
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
||||
<DonutChart :data="categories" :loading="loadingCategories" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="7">
|
||||
<div class="text-caption text-medium-emphasis mb-1">KUMULATIVNÍ VÝDAJE</div>
|
||||
<CumulativeSpendingChart :data="cumulative" :loading="loadingCumulative" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { MonthlyBalanceDto } from '../api/apiClient'
|
||||
|
||||
const props = defineProps<{
|
||||
data: MonthlyBalanceDto[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const MONTH_NAMES = ['Led','Úno','Bře','Dub','Kvě','Čvn','Čvc','Srp','Zář','Říj','Lis','Pro']
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
chart: { type: 'bar', background: 'transparent', toolbar: { show: false } },
|
||||
xaxis: { categories: props.data.map(d => MONTH_NAMES[(d.month ?? 1) - 1]) },
|
||||
theme: { mode: 'dark' },
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: {
|
||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
||||
},
|
||||
colors: ['#42a5f5'],
|
||||
}))
|
||||
|
||||
const series = computed(() => [{
|
||||
name: 'Zůstatek',
|
||||
data: props.data.map(d => d.closingBalance ?? 0)
|
||||
}])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
||||
<apexchart v-else type="bar" :options="chartOptions" :series="series" height="220" />
|
||||
</template>
|
||||
56
src/AccountTracking.Web/src/components/UploadCsvButton.vue
Normal file
56
src/AccountTracking.Web/src/components/UploadCsvButton.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { transactionsApi } from '../api'
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
const loading = ref(false)
|
||||
|
||||
function openPicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileSelected(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await transactionsApi.import({ fileName: file.name, data: file })
|
||||
snackbarText.value = `Importováno ${result.recordsImported}, přeskočeno ${result.recordsSkipped}.`
|
||||
snackbarColor.value = 'success'
|
||||
} catch (e: any) {
|
||||
const body = await e?.response?.json().catch(() => null)
|
||||
snackbarText.value = body?.error ?? 'Chyba při importu.'
|
||||
snackbarColor.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
snackbar.value = true
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<v-btn
|
||||
prepend-icon="mdi-upload"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
:loading="loading"
|
||||
@click="openPicker"
|
||||
>
|
||||
Nahrát CSV
|
||||
</v-btn>
|
||||
<v-snackbar v-model="snackbar" :color="snackbarColor" timeout="4000">
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
</template>
|
||||
74
src/AccountTracking.Web/src/components/YearSummary.vue
Normal file
74
src/AccountTracking.Web/src/components/YearSummary.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useDashPeriodStore } from '../stores/dashPeriod'
|
||||
import { dashboardApi } from '../api'
|
||||
import type { CategorySpendingDto, MonthlyBalanceDto, SummaryDto } from '../api/apiClient'
|
||||
import DonutChart from './DonutChart.vue'
|
||||
import MonthlyBalancesChart from './MonthlyBalancesChart.vue'
|
||||
|
||||
const period = useDashPeriodStore()
|
||||
|
||||
const summary = ref<SummaryDto | null>(null)
|
||||
const categories = ref<CategorySpendingDto[]>([])
|
||||
const balances = ref<MonthlyBalanceDto[]>([])
|
||||
const loadingSummary = ref(false)
|
||||
const loadingCategories = ref(false)
|
||||
const loadingBalances = ref(false)
|
||||
|
||||
async function load() {
|
||||
loadingSummary.value = true
|
||||
loadingCategories.value = true
|
||||
loadingBalances.value = true
|
||||
try {
|
||||
const [s, c, b] = await Promise.all([
|
||||
dashboardApi.summary(period.year, undefined),
|
||||
dashboardApi.spendingByCategory(period.year, undefined),
|
||||
dashboardApi.monthlyBalances(period.year),
|
||||
])
|
||||
summary.value = s
|
||||
categories.value = c
|
||||
balances.value = b
|
||||
} finally {
|
||||
loadingSummary.value = false
|
||||
loadingCategories.value = false
|
||||
loadingBalances.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => period.year, load, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card height="100%">
|
||||
<v-card-title class="d-flex align-center pa-3">
|
||||
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevYear" />
|
||||
<span class="flex-grow-1 text-center text-h6">{{ period.year }}</span>
|
||||
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextYear" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
||||
<v-row v-else class="mb-4">
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
||||
<div class="text-h6 text-error">{{ (summary?.totalSpent ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
||||
</v-col>
|
||||
<v-col cols="6" class="text-center">
|
||||
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
||||
<div class="text-h6 text-success">{{ (summary?.totalIncome ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="5">
|
||||
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
||||
<DonutChart :data="categories" :loading="loadingCategories" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="7">
|
||||
<div class="text-caption text-medium-emphasis mb-1">MĚSÍČNÍ ZŮSTATKY</div>
|
||||
<MonthlyBalancesChart :data="balances" :loading="loadingBalances" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
28
src/AccountTracking.Web/src/stores/dashPeriod.ts
Normal file
28
src/AccountTracking.Web/src/stores/dashPeriod.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useDashPeriodStore = defineStore('dashPeriod', () => {
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1) // 1-based
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const d = new Date(year.value, month.value - 1, 1)
|
||||
return d.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
function prevMonth() {
|
||||
if (month.value === 1) { month.value = 12; year.value-- }
|
||||
else month.value--
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (month.value === 12) { month.value = 1; year.value++ }
|
||||
else month.value++
|
||||
}
|
||||
|
||||
function prevYear() { year.value-- }
|
||||
function nextYear() { year.value++ }
|
||||
|
||||
return { year, month, monthLabel, prevMonth, nextMonth, prevYear, nextYear }
|
||||
})
|
||||
@ -1 +1,39 @@
|
||||
<template><div>Dashboard</div></template>
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import YearSummary from '../components/YearSummary.vue'
|
||||
import MonthSummary from '../components/MonthSummary.vue'
|
||||
import UploadCsvButton from '../components/UploadCsvButton.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar>
|
||||
<v-app-bar-title>Finance Tracker</v-app-bar-title>
|
||||
<template #append>
|
||||
<UploadCsvButton class="mr-2" />
|
||||
<v-btn to="/transactions" variant="text" class="mr-2">Transakce</v-btn>
|
||||
<v-btn icon="mdi-logout" variant="text" @click="logout" />
|
||||
</template>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid class="pa-4">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<YearSummary />
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<MonthSummary />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user