mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-17 16:24:25 +08:00
export statistics & analysis data in desktop version
This commit is contained in:
@@ -128,6 +128,11 @@
|
||||
:title="tt('Filter Transaction Tags')"
|
||||
@click="showFilterTagDialog = true"></v-list-item>
|
||||
<v-divider class="my-2"/>
|
||||
<v-list-item :prepend-icon="mdiExport"
|
||||
:title="tt('Export Results')"
|
||||
:disabled="!statisticsDataHasData"
|
||||
@click="exportResults"></v-list-item>
|
||||
<v-divider class="my-2"/>
|
||||
<v-list-item to="/app/settings?tab=statisticsSetting"
|
||||
:prepend-icon="mdiFilterCogOutline"
|
||||
:title="tt('Settings')"></v-list-item>
|
||||
@@ -270,6 +275,7 @@
|
||||
:enable-click-item="true"
|
||||
:default-currency="defaultCurrency"
|
||||
:show-total-amount-in-tooltip="showTotalAmountInTrendsChart"
|
||||
ref="trendsChart"
|
||||
id-field="id"
|
||||
name-field="name"
|
||||
value-field="totalAmount"
|
||||
@@ -317,14 +323,18 @@
|
||||
@settings:change="setTagFilter" />
|
||||
</v-dialog>
|
||||
|
||||
<export-dialog ref="exportDialog" />
|
||||
|
||||
<snack-bar ref="snackbar" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SnackBar from '@/components/desktop/SnackBar.vue';
|
||||
import TrendsChart from '@/components/desktop/TrendsChart.vue';
|
||||
import AccountFilterSettingsCard from '@/views/desktop/common/cards/AccountFilterSettingsCard.vue';
|
||||
import CategoryFilterSettingsCard from '@/views/desktop/common/cards/CategoryFilterSettingsCard.vue';
|
||||
import TransactionTagFilterSettingsCard from '@/views/desktop/common/cards/TransactionTagFilterSettingsCard.vue';
|
||||
import ExportDialog from '@/views/desktop/statistics/transaction/dialogs/ExportDialog.vue';
|
||||
|
||||
import { ref, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useRouter, onBeforeRouteUpdate } from 'vue-router';
|
||||
@@ -353,7 +363,10 @@ import {
|
||||
isString,
|
||||
isNumber,
|
||||
arrayItemToObjectField
|
||||
} from '@/lib/common.ts'
|
||||
} from '@/lib/common.ts';
|
||||
import {
|
||||
formatAmount
|
||||
} from '@/lib/numeral.ts';
|
||||
import {
|
||||
getYearAndMonthFromUnixTime,
|
||||
getYearMonthFirstUnixTime,
|
||||
@@ -373,10 +386,13 @@ import {
|
||||
mdiMenu,
|
||||
mdiFilterOutline,
|
||||
mdiFilterCogOutline,
|
||||
mdiExport,
|
||||
mdiDotsVertical
|
||||
} from '@mdi/js';
|
||||
|
||||
type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
type TrendsChartType = InstanceType<typeof TrendsChart>;
|
||||
type ExportDialogType = InstanceType<typeof ExportDialog>;
|
||||
|
||||
interface TransactionStatisticsProps {
|
||||
initAnalysisType?: string,
|
||||
@@ -433,6 +449,8 @@ const transactionCategoriesStore = useTransactionCategoriesStore();
|
||||
const statisticsStore = useStatisticsStore();
|
||||
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
const trendsChart = useTemplateRef<TrendsChartType>('trendsChart');
|
||||
const exportDialog = useTemplateRef<ExportDialogType>('exportDialog');
|
||||
|
||||
const activeTab = ref<string>('statisticsPage');
|
||||
const initing = ref<boolean>(true);
|
||||
@@ -446,6 +464,16 @@ const showFilterTagDialog = ref<boolean>(false);
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
|
||||
const statisticsDataHasData = computed<boolean>(() => {
|
||||
if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) {
|
||||
return !!categoricalAnalysisData.value && !!categoricalAnalysisData.value.items && categoricalAnalysisData.value.items.length > 0;
|
||||
} else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis) {
|
||||
return !!trendsAnalysisData.value && !!trendsAnalysisData.value.items && trendsAnalysisData.value.items.length > 0 && !!trendsChart.value;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const allChartTypes = computed<TypeAndDisplayName[]>(() => {
|
||||
if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) {
|
||||
return getAllCategoricalChartTypes();
|
||||
@@ -876,6 +904,31 @@ function setTagFilter(changed: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function exportResults(): void {
|
||||
if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis && categoricalAnalysisData.value && categoricalAnalysisData.value.items) {
|
||||
exportDialog.value?.open({
|
||||
headers: [
|
||||
tt('Name'),
|
||||
tt('Amount') + ` (${defaultCurrency.value})`,
|
||||
tt('Proportion (%)')
|
||||
],
|
||||
data: categoricalAnalysisData.value.items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => [
|
||||
item.name,
|
||||
formatAmount(item.totalAmount, {}),
|
||||
item.percent.toFixed(4)
|
||||
])
|
||||
});
|
||||
} else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis && trendsAnalysisData.value && trendsAnalysisData.value.items && trendsChart.value) {
|
||||
const exportData = trendsChart.value.exportData();
|
||||
exportDialog.value?.open({
|
||||
headers: exportData.headers || [],
|
||||
data: exportData.data || []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPieChartItem(item: Record<string, unknown>): void {
|
||||
router.push(getTransactionItemLinkUrl(item['id'] as string));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<v-dialog width="1000" v-model="showState">
|
||||
<v-card class="pa-2 pa-sm-4 pa-md-4">
|
||||
<template #title>
|
||||
<div class="d-flex align-center justify-center">
|
||||
<div class="d-flex w-100 align-center justify-center">
|
||||
<h4 class="text-h4">{{ tt('Export Results') }}</h4>
|
||||
</div>
|
||||
<v-btn density="comfortable" color="default" variant="text" class="ml-2" :icon="true">
|
||||
<v-icon :icon="mdiDotsVertical" />
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-subheader :title="tt('Field Separator')"/>
|
||||
<v-list-item :prepend-icon="mdiComma"
|
||||
:append-icon="separator === ',' ? mdiCheck : undefined"
|
||||
:title="tt('Comma')"
|
||||
@click="separator = ','"></v-list-item>
|
||||
<v-list-item :prepend-icon="mdiKeyboardTab"
|
||||
:append-icon="separator === '\t' ? mdiCheck : undefined"
|
||||
:title="tt('Tab')"
|
||||
@click="separator = '\t'"></v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-card-text class="py-0 w-100 d-flex justify-center">
|
||||
<v-switch class="export-data-display-switch" color="secondary"
|
||||
:label="tt('Raw Data')"
|
||||
v-model="showRawData"
|
||||
@click="showRawData = !showRawData">
|
||||
<template #prepend>
|
||||
<span>{{ tt('Table') }}</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="my-md-4 w-100 d-flex justify-center">
|
||||
<v-data-table
|
||||
fixed-header
|
||||
fixed-footer
|
||||
multi-sort
|
||||
density="compact"
|
||||
height="365"
|
||||
:headers="dataTableHeaders"
|
||||
:items="dataTableItems"
|
||||
:hover="true"
|
||||
:hide-default-footer="true"
|
||||
:items-per-page="dataTableItems.length"
|
||||
:no-data-text="tt('No data')"
|
||||
v-if="!showRawData"
|
||||
></v-data-table>
|
||||
<div class="w-100 pl-2 code-container" v-if="showRawData">
|
||||
<textarea class="w-100" style="outline: none; height: 360px" :readonly="true" :value="exportedData"></textarea>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="overflow-y-visible">
|
||||
<div ref="buttonContainer" class="w-100 d-flex justify-center gap-4">
|
||||
<v-btn-group variant="tonal" density="comfortable">
|
||||
<v-btn color="primary" :disabled="!exportedData" @click="copy">{{ tt('Copy') }}</v-btn>
|
||||
<v-btn density="compact" color="primary" :disabled="!exportedData" :icon="true">
|
||||
<v-icon :icon="mdiMenuDown" size="24" />
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item :title="tt('Save')" @click="save()"></v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<snack-bar ref="snackbar" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SnackBar from '@/components/desktop/SnackBar.vue';
|
||||
|
||||
import { ref, computed, useTemplateRef } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { copyTextToClipboard, startDownloadFile } from '@/lib/ui/common.ts';
|
||||
|
||||
import {
|
||||
mdiDotsVertical,
|
||||
mdiCheck,
|
||||
mdiComma,
|
||||
mdiKeyboardTab,
|
||||
mdiMenuDown
|
||||
} from '@mdi/js';
|
||||
|
||||
type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const { tt } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const buttonContainer = useTemplateRef<HTMLElement>('buttonContainer');
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
|
||||
const showState = ref<boolean>(false);
|
||||
const headers = ref<string[]>([]);
|
||||
const data = ref<string[][]>([]);
|
||||
const separator = ref<string>(',');
|
||||
const showRawData = ref<boolean>(false);
|
||||
|
||||
const dataTableHeaders = computed<object[]>(() => {
|
||||
return headers.value.map((header, index) => ({
|
||||
key: index.toString(),
|
||||
value: `column${index}`,
|
||||
title: header,
|
||||
sortable: index > 0,
|
||||
nowrap: true
|
||||
}));
|
||||
});
|
||||
|
||||
const dataTableItems = computed<object[]>(() => {
|
||||
return data.value.map(row => {
|
||||
const item: Record<string, string> = {};
|
||||
|
||||
row.forEach((value, index) => {
|
||||
item[`column${index}`] = value;
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
});
|
||||
|
||||
const exportedData = computed<string>(() => {
|
||||
let ret = '';
|
||||
|
||||
if (headers.value.length > 0) {
|
||||
ret += headers.value.join(separator.value);
|
||||
}
|
||||
|
||||
for (const row of data.value) {
|
||||
ret += '\n';
|
||||
ret += row.join(separator.value);
|
||||
}
|
||||
|
||||
return ret;
|
||||
});
|
||||
|
||||
function getExportFileName(fileExtension: string): string {
|
||||
const nickname = userStore.currentUserNickname;
|
||||
|
||||
if (nickname) {
|
||||
return tt('dataExport.exportStatisticsFileName', {
|
||||
nickname: nickname
|
||||
}) + '.' + fileExtension;
|
||||
}
|
||||
|
||||
return tt('dataExport.defaultExportStatisticsFileName') + '.' + fileExtension;
|
||||
}
|
||||
|
||||
function open(options: { headers: string[], data: string[][] }): void {
|
||||
headers.value = options.headers || [];
|
||||
data.value = options.data || [];
|
||||
separator.value = ',';
|
||||
showRawData.value = false;
|
||||
showState.value = true;
|
||||
}
|
||||
|
||||
function copy(): void {
|
||||
copyTextToClipboard(exportedData.value, buttonContainer.value);
|
||||
snackbar.value?.showMessage('Data copied');
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
let fileExtension = 'csv';
|
||||
let contentType = 'text/csv';
|
||||
|
||||
if (separator.value === '\t') {
|
||||
fileExtension = 'tsv';
|
||||
contentType = 'text/tab-separated-values';
|
||||
}
|
||||
|
||||
startDownloadFile(getExportFileName(fileExtension), new Blob([exportedData.value], { type: contentType }));
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
showState.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.export-data-display-switch {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-data-display-switch.v-input--horizontal .v-input__prepend {
|
||||
margin-right: 10px; /* same as the padding-left of `.v-switch .v-label` */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user