Files
luminic-front/src/components/analytics/AnalyticsView.vue
Vladimir Voronin a685c67395 +analytics update
2025-01-23 18:09:23 +03:00

288 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import LoadingView from "@/components/LoadingView.vue";
import {computed, onMounted, ref} from "vue";
import {getCategories, getCategoriesSumsRequest} from "@/services/categoryService";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Chart from "primevue/chart";
import Listbox from "primevue/listbox";
import Select from "primevue/select";
import Accordion from "primevue/accordion";
import AccordionPanel from "primevue/accordionpanel";
import AccordionHeader from "primevue/accordionheader";
import AccordionContent from "primevue/accordioncontent"
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {Chart as ChartJS} from 'chart.js/auto';
import {Colors} from 'chart.js';
import {getMonthName} from "@/utils/utils";
ChartJS.register(ChartDataLabels);
ChartJS.register(Colors);
const loading = ref(true);
const categoriesCatalog = ref([])
const categories = ref([]);
const dataTableCategories = ref([]);
const tableColumns = ref([]);
const chartData = ref(null)
const selectedCategory = ref()
const formatter = ref(new Intl.NumberFormat('ru-RU', {style: 'currency', currency: 'RUB', minimumFractionDigits: 0, maximumFractionDigits:0}))
const isChartOpen = ref(false)
const closeChart = () => {
setTimeout(() => {
isChartOpen.value = false;
}, 500)
}
const preparedChartData = computed(() => {
if (categories.value && selectedCategory.value) {
const r = Math.random() * 255
const g = Math.random() * 255
const b = Math.random() * 255
const colorLine = `rgba(${r}, ${g}, ${b},1)` // Случайный цвет для графика
const colorBackground = `rgba(${r}, ${g}, ${b},0.2)` // Случайный цвет для графика
return {
labels: categories.value[0].monthlySums.map((month) => month.date), // Используем даты как метки
datasets: [{
label: categories.value.filter((it) => selectedCategory.value.id == it._id)[0].categoryName, // Название категории
data: categories.value.filter((it) => selectedCategory.value.id == it._id)[0].monthlySums.map((month) => month.total), // Данные по total
borderColor: colorLine,
fill: true,
backgroundColor: colorBackground,
tension: 0.4
}]
}
}
})
const chartOptions = {
responsive: true,
plugins: {
legend: {
display: true,
position: "top",
},
title: {
display: true,
text: "График расходов по категориям",
},
colors: {
enabled: true,
},
datalabels: {
formatter: function (value) {
return formatter.value.format(value);
},
align: 'top',
offset: 2,
labels: {
title: {
font: {
weight: 'bold'
}
},
value: {
color: 'green'
}
}
}
},
scales: {
x: {
title: {
display: true,
text: "Месяц",
},
},
y: {
title: {
display: true,
text: "Сумма",
},
},
},
}
// Преобразование данных для таблицы
const prepareTableData = (categories) => {
// 1. Собираем все уникальные значения date из monthlySums
const allDates = [
...new Set(
categories.flatMap((category) =>
category.monthlySums.map((sumItem) => sumItem.date)
)
),
];
// 2. Сортируем даты.
// Если у вас формат "YYYY-MM-DD", лексикографическая сортировка работает корректно хронологически:
// allDates.sort((a, b) => a.localeCompare(b));
// (Если хотите гарантированно использовать объекты Date, можно так:
// allDates.sort((a, b) => new Date(a) - new Date(b));
// Но тогда поля колонки будут тоже строками вида "2024-09-01" — обычно это ок.)
// 3. Формируем колонки для DataTable:
// - Первый столбец: "category"
// - Далее по одному столбцу на каждую дату
tableColumns.value = [
{field: "category", header: "Категория"},
...allDates.map((dateStr) => ({field: dateStr, header: dateStr})),
{field: "avg", header: "Среднее"},
];
console.log(tableColumns.value[0].field);
const sums = {}
// 4. Формируем строки (для каждой категории)
const rows = categories.map((category) => {
// Начинаем со строки, где есть поле с именем категории
const row = {category: category.categoryIcon + " " + category.categoryName};
let categorySum = 0
// Для каждой даты проверяем, есть ли в monthlySums соответствующая запись
allDates.forEach((dateStr) => {
const found = category.monthlySums.find((m) => m.date === dateStr);
if (found.difference != 0) {
if (found.difference > 0) {
row[dateStr] = found ? formatter.value.format(found.total) + "<p class='text-red-500 text-sm'> (+ " + found.difference + "%)</p>" : 0;
} else {
row[dateStr] = found ? formatter.value.format(found.total) + "<p class='text-green-600 text-sm'> (" + found.difference + "%)</p>" : 0;
}
} else {
row[dateStr] = found ? formatter.value.format(found.total) : 0;
}
if (!sums[dateStr]) {
sums[dateStr] = 0
}
sums[dateStr] += found.total
categorySum += found.total
});
row["avg"] = formatter.value.format(categorySum/allDates.length);
return row;
});
let previousSum = 0;
Object.keys(sums).forEach(key => {
console.log(previousSum)
let difference = previousSum != 0 ? ((sums[key] - previousSum) / previousSum) * 100 : 0
if (sums[key] != previousSum) {
previousSum = sums[key];
}
let color = ""
if (difference > 0) {
color = "text-red-500"
} else color = "text-green-600"
sums[key] = formatter.value.format(sums[key]) + `<p class='${color}'>(` + difference.toFixed(0) + "%)</p>";
});
rows.push(sums);
return rows;
};
const fetchCategoriesSums = async () => {
loading.value = true;
await getCategoriesSumsRequest().then((data) => {
data.data.forEach((category) => {
category.monthlySums.forEach((monthlySum) => {
monthlySum.date = getMonthName(new Date(monthlySum.date).getMonth()) + " " + new Date(monthlySum.date).getFullYear()
})
})
categories.value = data.data;
console.log(categories.value);
dataTableCategories.value = prepareTableData(data.data);
});
loading.value = false;
};
const fetchCategoriesCatalog = async () => {
await getCategories("EXPENSE").then((data) => {
categoriesCatalog.value = data.data
selectedCategory.value = data.data[0]
})
}
onMounted(async () => {
await Promise.all([fetchCategoriesSums(), fetchCategoriesCatalog()])
// await fetchCategoriesSums();
});
</script>
<template>
<LoadingView v-if="loading"/>
<div v-else class="p-4 bg-gray-100 h-full flex flex-col gap-4 items-center justify-items-center ">
<Accordion value="0" class=" !w-5/6 !items-center !justify-items-start" @tab-open="isChartOpen=true"
@tab-close="closeChart">
<AccordionPanel value="1">
<AccordionHeader>График</AccordionHeader>
<AccordionContent class="items-center justify-items-center ">
<!-- <Select v-model="selectedCategory" :options="categoriesCatalog" optionLabel="name"-->
<!-- placeholder="Выберите категории"-->
<!-- :maxSelectedLabels="3" class="w-full md:w-80"/>-->
<div v-if="isChartOpen" class="flex flex-row items-start justify-items-start w-full">
<Listbox v-model="selectedCategory" :options="categoriesCatalog" filter optionLabel="name"
class="!w-fit !h-5/6 md:w-56">
<template #option="slotProps">
<div>{{ slotProps.option.icon }} {{ slotProps.option.name }}</div>
</template>
</Listbox>
<Chart type="line" :data="preparedChartData" :options="chartOptions" class="!w-5/6 !h-full"/>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
<DataTable :value="dataTableCategories" responsiveLayout="scroll" filter stripedRows class="w-5/6 items-center">
<Column
:field="tableColumns[0].field"
:header="tableColumns[0].header"
:bodyCellClass="'bold-column'"
:headerCellClass="'bold-column'"
class="font-bold"
/>
<!-- Остальные колонки -->
<Column
v-for="(col, index) in tableColumns.slice(1)"
:key="col.field"
:field="col.field"
:header="col.header"
>
<template #body="{ data }">
<span v-html="data[col.field] " class="text-center"></span>
</template>
</Column>
</DataTable>
</div>
</template>
<style>
.p-accordioncontent-content {
display: flex;
flex-direction: row;
align-items: start;
align-content: center;
justify-content: center;
}
.p-listbox-list-container {
max-height: 100% !important;
}
</style>