feat: dashboard page with year/month summary, charts and CSV upload

This commit is contained in:
Martin 2026-03-20 01:13:35 +01:00
parent 19aca0e5fc
commit b1fbc921cd
8 changed files with 394 additions and 1 deletions

View File

@ -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')}` }
},
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>

View 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')}` }
}
}))
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') }} </span>
</div>
</div>
</div>
</template>

View 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') }} </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') }} </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>

View File

@ -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')}` }
},
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>

View 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>

View 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') }} </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') }} </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>

View 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 }
})

View File

@ -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>