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