mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-20 01:34:24 +08:00
migrate income&expense overview card and monthly income&expense card to composition API and typescript
This commit is contained in:
+5
-1
@@ -3,7 +3,11 @@ import { ALL_CURRENCIES, DEFAULT_CURRENCY_SYMBOL } from '@/consts/currency.ts';
|
|||||||
|
|
||||||
import { isString, isNumber } from './common.ts';
|
import { isString, isNumber } from './common.ts';
|
||||||
|
|
||||||
export function getCurrencyFraction(currencyCode: string): number | undefined {
|
export function getCurrencyFraction(currencyCode?: string): number | undefined {
|
||||||
|
if (!currencyCode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const currencyInfo = ALL_CURRENCIES[currencyCode];
|
const currencyInfo = ALL_CURRENCIES[currencyCode];
|
||||||
return currencyInfo?.fraction;
|
return currencyInfo?.fraction;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getSystemTheme(): ThemeType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExpenseAndIncomeAmountColor(expenseAmountColorType: number, incomeAmountColorType: number, isDarkMode: boolean): AmountColor {
|
export function getExpenseAndIncomeAmountColor(expenseAmountColorType: number, incomeAmountColorType: number, isDarkMode?: boolean): AmountColor {
|
||||||
let expenseAmountColor = expenseAmountColorType ? PresetAmountColor.valueOf(expenseAmountColorType) : null;
|
let expenseAmountColor = expenseAmountColorType ? PresetAmountColor.valueOf(expenseAmountColorType) : null;
|
||||||
let incomeAmountColor = incomeAmountColorType ? PresetAmountColor.valueOf(incomeAmountColorType) : null;
|
let incomeAmountColor = incomeAmountColorType ? PresetAmountColor.valueOf(incomeAmountColorType) : null;
|
||||||
|
|
||||||
|
|||||||
+224
-6
@@ -17,20 +17,54 @@ import {
|
|||||||
LongTimeFormat,
|
LongTimeFormat,
|
||||||
ShortTimeFormat
|
ShortTimeFormat
|
||||||
} from '@/core/datetime.ts';
|
} from '@/core/datetime.ts';
|
||||||
import { type LocalizedAccountCategory, AccountType, AccountCategory } from '@/core/account.ts';
|
|
||||||
import { TransactionEditScopeType, TransactionTagFilterType } from '@/core/transaction.ts';
|
import {
|
||||||
import { ScheduledTemplateFrequencyType } from '@/core/template.ts';
|
type NumberFormatOptions,
|
||||||
import { StatisticsAnalysisType, CategoricalChartType, TrendChartType, ChartDataType, ChartSortingType, ChartDateAggregationType } from '@/core/statistics.ts';
|
DecimalSeparator,
|
||||||
|
DigitGroupingSymbol,
|
||||||
|
DigitGroupingType
|
||||||
|
} from '@/core/numeral.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CurrencyPrependAndAppendText,
|
||||||
|
CurrencyDisplayType
|
||||||
|
} from '@/core/currency.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LocalizedAccountCategory,
|
||||||
|
AccountType,
|
||||||
|
AccountCategory
|
||||||
|
} from '@/core/account.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TransactionEditScopeType,
|
||||||
|
TransactionTagFilterType
|
||||||
|
} from '@/core/transaction.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ScheduledTemplateFrequencyType
|
||||||
|
} from '@/core/template.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
StatisticsAnalysisType,
|
||||||
|
CategoricalChartType,
|
||||||
|
TrendChartType,
|
||||||
|
ChartDataType,
|
||||||
|
ChartSortingType,
|
||||||
|
ChartDateAggregationType
|
||||||
|
} from '@/core/statistics.ts';
|
||||||
|
|
||||||
import type { LocaleDefaultSettings } from '@/core/setting.ts';
|
import type { LocaleDefaultSettings } from '@/core/setting.ts';
|
||||||
import type { ErrorResponse } from '@/core/api.ts';
|
import type { ErrorResponse } from '@/core/api.ts';
|
||||||
|
|
||||||
|
import { ALL_CURRENCIES } from '@/consts/currency.ts';
|
||||||
import { KnownErrorCode, SPECIFIED_API_NOT_FOUND_ERRORS, PARAMETERIZED_ERRORS } from '@/consts/api.ts';
|
import { KnownErrorCode, SPECIFIED_API_NOT_FOUND_ERRORS, PARAMETERIZED_ERRORS } from '@/consts/api.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isObject,
|
||||||
isString,
|
isString,
|
||||||
isNumber,
|
isNumber,
|
||||||
isObject
|
isBoolean
|
||||||
} from '@/lib/common.ts';
|
} from '@/lib/common.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -40,8 +74,21 @@ import {
|
|||||||
getDateTimeFormatType
|
getDateTimeFormatType
|
||||||
} from '@/lib/datetime.ts';
|
} from '@/lib/datetime.ts';
|
||||||
|
|
||||||
import logger from '@/lib/logger.ts';
|
import {
|
||||||
|
appendDigitGroupingSymbol,
|
||||||
|
parseAmount,
|
||||||
|
formatAmount,
|
||||||
|
formatExchangeRateAmount,
|
||||||
|
getAdaptiveDisplayAmountRate
|
||||||
|
} from '@/lib/numeral.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCurrencyFraction,
|
||||||
|
getAmountPrependAndAppendCurrencySymbol, appendCurrencySymbol
|
||||||
|
} from '@/lib/currency.ts';
|
||||||
|
|
||||||
import services from '@/lib/services.ts';
|
import services from '@/lib/services.ts';
|
||||||
|
import logger from '@/lib/logger.ts';
|
||||||
|
|
||||||
import { useUserStore } from '@/stores/user.ts';
|
import { useUserStore } from '@/stores/user.ts';
|
||||||
|
|
||||||
@@ -306,6 +353,30 @@ export function useI18n() {
|
|||||||
return getLocalizedDateTimeFormat<ShortTimeFormat>('shortTime', ShortTimeFormat.all(), ShortTimeFormat.values(), userStore.currentUserShortTimeFormat, 'shortTimeFormat', ShortTimeFormat.Default);
|
return getLocalizedDateTimeFormat<ShortTimeFormat>('shortTime', ShortTimeFormat.all(), ShortTimeFormat.values(), userStore.currentUserShortTimeFormat, 'shortTimeFormat', ShortTimeFormat.Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNumberFormatOptions(currencyCode?: string): NumberFormatOptions {
|
||||||
|
return {
|
||||||
|
decimalSeparator: getCurrentDecimalSeparator(userStore.currentUserDecimalSeparator),
|
||||||
|
decimalNumberCount: getCurrencyFraction(currencyCode),
|
||||||
|
digitGroupingSymbol: getCurrentDigitGroupingSymbol(userStore.currentUserDigitGroupingSymbol),
|
||||||
|
digitGrouping: getCurrentDigitGroupingType(userStore.currentUserDigitGrouping),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentCurrencyDisplayType(): CurrencyDisplayType {
|
||||||
|
let currencyDisplayType = CurrencyDisplayType.valueOf(userStore.currentUserCurrencyDisplayType);
|
||||||
|
|
||||||
|
if (!currencyDisplayType) {
|
||||||
|
const defaultCurrencyDisplayTypeName = t('default.currencyDisplayType');
|
||||||
|
currencyDisplayType = CurrencyDisplayType.parse(defaultCurrencyDisplayTypeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currencyDisplayType) {
|
||||||
|
currencyDisplayType = CurrencyDisplayType.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currencyDisplayType;
|
||||||
|
}
|
||||||
|
|
||||||
// public functions
|
// public functions
|
||||||
function translateIf(text: string, isTranslate: boolean): string {
|
function translateIf(text: string, isTranslate: boolean): string {
|
||||||
if (isTranslate) {
|
if (isTranslate) {
|
||||||
@@ -373,6 +444,20 @@ export function useI18n() {
|
|||||||
return t(`currency.name.${currencyCode}`);
|
return t(`currency.name.${currencyCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrencyUnitName(currencyCode: string, isPlural: boolean): string {
|
||||||
|
const currencyInfo = ALL_CURRENCIES[currencyCode];
|
||||||
|
|
||||||
|
if (currencyInfo && currencyInfo.unit) {
|
||||||
|
if (isPlural) {
|
||||||
|
return t(`currency.unit.${currencyInfo.unit}.plural`);
|
||||||
|
} else {
|
||||||
|
return t(`currency.unit.${currencyInfo.unit}.normal`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getAllMeridiemIndicators(): LocalizedMeridiemIndicator {
|
function getAllMeridiemIndicators(): LocalizedMeridiemIndicator {
|
||||||
const allMeridiemIndicators = MeridiemIndicator.values();
|
const allMeridiemIndicators = MeridiemIndicator.values();
|
||||||
const meridiemIndicatorNames = [];
|
const meridiemIndicatorNames = [];
|
||||||
@@ -527,6 +612,51 @@ export function useI18n() {
|
|||||||
return joinMultiText(finalWeekdayNames);
|
return joinMultiText(finalWeekdayNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentDecimalSeparator(decimalSeparator: number): string {
|
||||||
|
let decimalSeparatorType = DecimalSeparator.valueOf(decimalSeparator);
|
||||||
|
|
||||||
|
if (!decimalSeparatorType) {
|
||||||
|
const defaultDecimalSeparatorTypeName = t('default.decimalSeparator');
|
||||||
|
decimalSeparatorType = DecimalSeparator.parse(defaultDecimalSeparatorTypeName);
|
||||||
|
|
||||||
|
if (!decimalSeparatorType) {
|
||||||
|
decimalSeparatorType = DecimalSeparator.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decimalSeparatorType.symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentDigitGroupingSymbol(digitGroupingSymbol: number): string {
|
||||||
|
let digitGroupingSymbolType = DigitGroupingSymbol.valueOf(digitGroupingSymbol);
|
||||||
|
|
||||||
|
if (!digitGroupingSymbolType) {
|
||||||
|
const defaultDigitGroupingSymbolTypeName = t('default.digitGroupingSymbol');
|
||||||
|
digitGroupingSymbolType = DigitGroupingSymbol.parse(defaultDigitGroupingSymbolTypeName);
|
||||||
|
|
||||||
|
if (!digitGroupingSymbolType) {
|
||||||
|
digitGroupingSymbolType = DigitGroupingSymbol.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return digitGroupingSymbolType.symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentDigitGroupingType(digitGrouping: number): number {
|
||||||
|
let digitGroupingType = DigitGroupingType.valueOf(digitGrouping);
|
||||||
|
|
||||||
|
if (!digitGroupingType) {
|
||||||
|
const defaultDigitGroupingTypeName = t('default.digitGrouping');
|
||||||
|
digitGroupingType = DigitGroupingType.parse(defaultDigitGroupingTypeName);
|
||||||
|
|
||||||
|
if (!digitGroupingType) {
|
||||||
|
digitGroupingType = DigitGroupingType.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return digitGroupingType.type;
|
||||||
|
}
|
||||||
|
|
||||||
function isLongDateMonthAfterYear() {
|
function isLongDateMonthAfterYear() {
|
||||||
return getLocalizedDateTimeType(LongDateFormat.all(), LongDateFormat.values(), userStore.currentUserLongDateFormat, 'longDateFormat', LongDateFormat.Default).isMonthAfterYear;
|
return getLocalizedDateTimeType(LongDateFormat.all(), LongDateFormat.values(), userStore.currentUserLongDateFormat, 'longDateFormat', LongDateFormat.Default).isMonthAfterYear;
|
||||||
}
|
}
|
||||||
@@ -562,6 +692,84 @@ export function useI18n() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNumberWithDigitGroupingSymbol(value: number | string): string {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions();
|
||||||
|
return appendDigitGroupingSymbol(value, numberFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParsedAmountNumber(value: string): number {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions();
|
||||||
|
return parseAmount(value, numberFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedAmount(value: number | string, currencyCode: string): string {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions(currencyCode);
|
||||||
|
return formatAmount(value, numberFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedAmountWithCurrency(value: number | string, currencyCode?: string, notConvertValue?: boolean, currencyDisplayType?: CurrencyDisplayType): string | null {
|
||||||
|
if (!isNumber(value) && !isString(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(value)) {
|
||||||
|
value = value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let textualValue = value as string;
|
||||||
|
const isPlural: boolean = textualValue !== '100' && textualValue !== '-100';
|
||||||
|
|
||||||
|
if (!notConvertValue) {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions();
|
||||||
|
const hasIncompleteFlag = isString(textualValue) && textualValue.charAt(textualValue.length - 1) === '+';
|
||||||
|
|
||||||
|
if (hasIncompleteFlag) {
|
||||||
|
textualValue = textualValue.substring(0, textualValue.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textualValue = formatAmount(textualValue, numberFormatOptions);
|
||||||
|
|
||||||
|
if (hasIncompleteFlag) {
|
||||||
|
textualValue = textualValue + '+';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBoolean(currencyCode) && !currencyCode) {
|
||||||
|
currencyCode = userStore.currentUserDefaultCurrency;
|
||||||
|
} else if (isBoolean(currencyCode) && !currencyCode) {
|
||||||
|
currencyCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currencyCode) {
|
||||||
|
return textualValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currencyDisplayType) {
|
||||||
|
currencyDisplayType = getCurrentCurrencyDisplayType();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyUnit = getCurrencyUnitName(currencyCode, isPlural);
|
||||||
|
const currencyName = getCurrencyName(currencyCode);
|
||||||
|
return appendCurrencySymbol(textualValue, currencyDisplayType, currencyCode, currencyUnit, currencyName, isPlural);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedExchangeRateAmount(value: number | string): string {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions();
|
||||||
|
return formatExchangeRateAmount(value, numberFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdaptiveAmountRate(amount1: number, amount2: number, fromExchangeRate: { rate: string }, toExchangeRate: { rate: string }): string | null {
|
||||||
|
const numberFormatOptions = getNumberFormatOptions();
|
||||||
|
return getAdaptiveDisplayAmountRate(amount1, amount2, fromExchangeRate, toExchangeRate, numberFormatOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmountPrependAndAppendText(currencyCode: string, isPlural: boolean): CurrencyPrependAndAppendText | null {
|
||||||
|
const currencyDisplayType = getCurrentCurrencyDisplayType();
|
||||||
|
const currencyUnit = getCurrencyUnitName(currencyCode, isPlural);
|
||||||
|
const currencyName = getCurrencyName(currencyCode);
|
||||||
|
return getAmountPrependAndAppendCurrencySymbol(currencyDisplayType, currencyCode, currencyUnit, currencyName, isPlural);
|
||||||
|
}
|
||||||
|
|
||||||
function setLanguage(languageKey: string | null, force?: boolean): LocaleDefaultSettings | null {
|
function setLanguage(languageKey: string | null, force?: boolean): LocaleDefaultSettings | null {
|
||||||
if (!languageKey) {
|
if (!languageKey) {
|
||||||
languageKey = getDefaultLanguage();
|
languageKey = getDefaultLanguage();
|
||||||
@@ -677,6 +885,9 @@ export function useI18n() {
|
|||||||
getWeekdayLongName,
|
getWeekdayLongName,
|
||||||
getMultiMonthdayShortNames,
|
getMultiMonthdayShortNames,
|
||||||
getMultiWeekdayLongNames,
|
getMultiWeekdayLongNames,
|
||||||
|
getCurrentDecimalSeparator,
|
||||||
|
getCurrentDigitGroupingSymbol,
|
||||||
|
getCurrentDigitGroupingType,
|
||||||
isLongDateMonthAfterYear,
|
isLongDateMonthAfterYear,
|
||||||
isShortDateMonthAfterYear,
|
isShortDateMonthAfterYear,
|
||||||
isLongTime24HourFormat,
|
isLongTime24HourFormat,
|
||||||
@@ -696,6 +907,13 @@ export function useI18n() {
|
|||||||
formatUnixTimeToLongTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedLongTimeFormat(), utcOffset, currentUtcOffset),
|
formatUnixTimeToLongTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedLongTimeFormat(), utcOffset, currentUtcOffset),
|
||||||
formatUnixTimeToShortTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedShortTimeFormat(), utcOffset, currentUtcOffset),
|
formatUnixTimeToShortTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedShortTimeFormat(), utcOffset, currentUtcOffset),
|
||||||
formatYearQuarter,
|
formatYearQuarter,
|
||||||
|
appendDigitGroupingSymbol: getNumberWithDigitGroupingSymbol,
|
||||||
|
parseAmount: getParsedAmountNumber,
|
||||||
|
formatAmount: getFormattedAmount,
|
||||||
|
formatAmountWithCurrency: getFormattedAmountWithCurrency,
|
||||||
|
formatExchangeRateAmount: getFormattedExchangeRateAmount,
|
||||||
|
getAdaptiveAmountRate,
|
||||||
|
getAmountPrependAndAppendText,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
setTimeZone,
|
setTimeZone,
|
||||||
initLocale
|
initLocale
|
||||||
|
|||||||
@@ -234,3 +234,11 @@ export interface TransactionOverviewResponseItem {
|
|||||||
incompleteExpenseAmount: boolean;
|
incompleteExpenseAmount: boolean;
|
||||||
amounts?: TransactionAmountsResponseItemAmountInfo[];
|
amounts?: TransactionAmountsResponseItemAmountInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransactionMonthlyIncomeAndExpenseData {
|
||||||
|
monthStartTime: number;
|
||||||
|
incomeAmount: number;
|
||||||
|
expenseAmount: number;
|
||||||
|
incompleteIncomeAmount: boolean;
|
||||||
|
incompleteExpenseAmount: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<v-skeleton-loader class="skeleton-no-margin mt-4 mb-8" type="text" width="120px" :loading="true" v-else-if="loading && !incomeAmount"></v-skeleton-loader>
|
<v-skeleton-loader class="skeleton-no-margin mt-4 mb-8" type="text" width="120px" :loading="true" v-else-if="loading && !incomeAmount"></v-skeleton-loader>
|
||||||
<div class="text-truncate text-h5 text-expense" v-if="!loading || expenseAmount">{{ expenseAmount }}</div>
|
<div class="text-truncate text-h5 text-expense" v-if="!loading || expenseAmount">{{ expenseAmount }}</div>
|
||||||
<v-skeleton-loader class="skeleton-no-margin mb-1" style="padding-bottom: 2px" type="text" width="120px" :loading="true" v-else-if="loading && !expenseAmount"></v-skeleton-loader>
|
<v-skeleton-loader class="skeleton-no-margin mb-1" style="padding-bottom: 2px" type="text" width="120px" :loading="true" v-else-if="loading && !expenseAmount"></v-skeleton-loader>
|
||||||
<div class="text-truncate text-h5 mt-2 mb-7" style="padding-bottom: 2px" v-if="!loading && !incomeAmount && !expenseAmount">{{ $t('No data') }}</div>
|
<div class="text-truncate text-h5 mt-2 mb-7" style="padding-bottom: 2px" v-if="!loading && !incomeAmount && !expenseAmount">{{ tt('No data') }}</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-text class="mt-6">
|
<v-card-text class="mt-6">
|
||||||
<span class="text-caption">{{ datetime }}</span>
|
<span class="text-caption">{{ datetime }}</span>
|
||||||
@@ -28,27 +28,26 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '@/locales/helpers.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mdiDotsVertical
|
mdiDotsVertical
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
||||||
export default {
|
defineProps<{
|
||||||
props: [
|
loading: boolean;
|
||||||
'loading',
|
disabled: boolean;
|
||||||
'disabled',
|
icon: string;
|
||||||
'icon',
|
title: string;
|
||||||
'title',
|
expenseAmount: string;
|
||||||
'expenseAmount',
|
incomeAmount: string;
|
||||||
'incomeAmount',
|
datetime: string;
|
||||||
'datetime'
|
}>();
|
||||||
],
|
|
||||||
data() {
|
const { tt } = useI18n();
|
||||||
return {
|
|
||||||
icons: {
|
const icons = {
|
||||||
more: mdiDotsVertical
|
more: mdiDotsVertical
|
||||||
}
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card :class="{ 'disabled': disabled }">
|
<v-card :class="{ 'disabled': disabled }">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ $t('Income and Expense Trends') }}</span>
|
<span>{{ tt('Income and Expense Trends') }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-if="loading && !hasAnyData">
|
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-if="loading && !hasAnyData">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-else-if="!loading && !hasAnyData">
|
<v-card-text class="overview-monthly-chart-container overview-monthly-chart-overlay" v-else-if="!loading && !hasAnyData">
|
||||||
<div class="d-flex flex-column align-center justify-center w-100 h-100">
|
<div class="d-flex flex-column align-center justify-center w-100 h-100">
|
||||||
<h2 style="margin-top: -40px">{{ $t('No data') }}</h2>
|
<h2 style="margin-top: -40px">{{ tt('No data') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
@@ -24,282 +24,290 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { useTheme } from 'vuetify';
|
import { computed } from 'vue';
|
||||||
|
import type { ECElementEvent } from 'echarts/core';
|
||||||
|
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||||
|
|
||||||
|
import { useI18n } from '@/locales/helpers.ts';
|
||||||
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useSettingsStore } from '@/stores/setting.ts';
|
import { useSettingsStore } from '@/stores/setting.ts';
|
||||||
import { useUserStore } from '@/stores/user.ts';
|
import { useUserStore } from '@/stores/user.ts';
|
||||||
|
|
||||||
import { TransactionType } from '@/core/transaction.ts';
|
import { TransactionType } from '@/core/transaction.ts';
|
||||||
|
import { type TransactionMonthlyIncomeAndExpenseData } from '@/models/transaction.ts';
|
||||||
import {
|
import {
|
||||||
parseDateFromUnixTime,
|
parseDateFromUnixTime,
|
||||||
getMonthName
|
getMonthName
|
||||||
} from '@/lib/datetime.ts';
|
} from '@/lib/datetime.ts';
|
||||||
import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||||
|
|
||||||
export default {
|
interface MonthlyIncomeAndExpenseCardClickEvent {
|
||||||
props: [
|
transactionType: TransactionType;
|
||||||
'loading',
|
monthStartTime: number;
|
||||||
'data',
|
}
|
||||||
'disabled',
|
|
||||||
'isDarkMode',
|
const props = defineProps<{
|
||||||
'enableClickItem'
|
loading: boolean;
|
||||||
],
|
data: TransactionMonthlyIncomeAndExpenseData[];
|
||||||
emits: [
|
disabled: boolean;
|
||||||
'click'
|
isDarkMode?: boolean;
|
||||||
],
|
enableClickItem?: boolean;
|
||||||
computed: {
|
}>();
|
||||||
...mapStores(useSettingsStore, useUserStore),
|
const emit = defineEmits<{
|
||||||
showAmountInHomePage() {
|
(e: 'click', event: MonthlyIncomeAndExpenseCardClickEvent): void;
|
||||||
return this.settingsStore.appSettings.showAmountInHomePage;
|
}>();
|
||||||
},
|
|
||||||
defaultCurrency() {
|
const { tt, getMonthShortName, formatAmountWithCurrency } = useI18n();
|
||||||
return this.userStore.currentUserDefaultCurrency;
|
|
||||||
},
|
const settingsStore = useSettingsStore();
|
||||||
hasAnyData() {
|
const userStore = useUserStore();
|
||||||
if (!this.data || !this.data.length || this.data.length < 1) {
|
|
||||||
return false;
|
const showAmountInHomePage = computed<boolean>(() => settingsStore.appSettings.showAmountInHomePage);
|
||||||
|
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
|
||||||
|
const hasAnyData = computed<boolean>(() => {
|
||||||
|
if (!props.data || !props.data.length || props.data.length < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < props.data.length; i++) {
|
||||||
|
const item = props.data[i];
|
||||||
|
|
||||||
|
if (item.incomeAmount > 0 || item.incomeAmount < 0 || item.expenseAmount > 0 || item.expenseAmount < 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed<object>(() => {
|
||||||
|
const monthNames: string[] = [];
|
||||||
|
const incomeAmounts: number[] = [];
|
||||||
|
const expenseAmounts: number[] = [];
|
||||||
|
let minAmount = 0;
|
||||||
|
let maxAmount = 0;
|
||||||
|
|
||||||
|
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, props.isDarkMode);
|
||||||
|
|
||||||
|
if (props.data) {
|
||||||
|
for (let i = 0; i < props.data.length; i++) {
|
||||||
|
const item = props.data[i];
|
||||||
|
const month = getMonthName(parseDateFromUnixTime(item.monthStartTime));
|
||||||
|
|
||||||
|
monthNames.push(getMonthShortName(month));
|
||||||
|
incomeAmounts.push(item.incomeAmount);
|
||||||
|
expenseAmounts.push(-item.expenseAmount);
|
||||||
|
|
||||||
|
if (item.incomeAmount > maxAmount) {
|
||||||
|
maxAmount = item.incomeAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < this.data.length; i++) {
|
if (-item.expenseAmount > maxAmount) {
|
||||||
const item = this.data[i];
|
maxAmount = -item.expenseAmount;
|
||||||
|
|
||||||
if (item.incomeAmount > 0 || item.incomeAmount < 0 || item.expenseAmount > 0 || item.expenseAmount < 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (item.incomeAmount < minAmount) {
|
||||||
},
|
minAmount = item.incomeAmount;
|
||||||
chartOptions() {
|
}
|
||||||
const self = this;
|
|
||||||
const monthNames = [];
|
|
||||||
const incomeAmounts = [];
|
|
||||||
const expenseAmounts = [];
|
|
||||||
let minAmount = 0;
|
|
||||||
let maxAmount = 0;
|
|
||||||
|
|
||||||
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(this.userStore.currentUserExpenseAmountColor, this.userStore.currentUserIncomeAmountColor, this.isDarkMode);
|
if (-item.expenseAmount < minAmount) {
|
||||||
|
minAmount = -item.expenseAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (self.data) {
|
const amountGap = maxAmount - minAmount;
|
||||||
for (let i = 0; i < self.data.length; i++) {
|
|
||||||
const item = self.data[i];
|
|
||||||
const month = getMonthName(parseDateFromUnixTime(item.monthStartTime));
|
|
||||||
|
|
||||||
monthNames.push(self.$locale.getMonthShortName(month));
|
return {
|
||||||
incomeAmounts.push(item.incomeAmount);
|
tooltip: {
|
||||||
expenseAmounts.push(-item.expenseAmount);
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
},
|
||||||
|
backgroundColor: props.isDarkMode ? '#333' : '#fff',
|
||||||
|
borderColor: props.isDarkMode ? '#333' : '#fff',
|
||||||
|
textStyle: {
|
||||||
|
color: props.isDarkMode ? '#eee' : '#333'
|
||||||
|
},
|
||||||
|
formatter: (params: CallbackDataParams[]) => {
|
||||||
|
let incomeAmount: string | null = null;
|
||||||
|
let expenseAmount: string | null = null;
|
||||||
|
|
||||||
if (item.incomeAmount > maxAmount) {
|
for (let i = 0; i < params.length; i++) {
|
||||||
maxAmount = item.incomeAmount;
|
const param = params[i];
|
||||||
}
|
const dataIndex = param.dataIndex;
|
||||||
|
const data = props.data[dataIndex];
|
||||||
|
|
||||||
if (-item.expenseAmount > maxAmount) {
|
if (param.seriesId === 'seriesIncome') {
|
||||||
maxAmount = -item.expenseAmount;
|
incomeAmount = getDisplayIncomeAmount(data);
|
||||||
}
|
} else if (param.seriesId === 'seriesExpense') {
|
||||||
|
expenseAmount = getDisplayExpenseAmount(data);
|
||||||
if (item.incomeAmount < minAmount) {
|
|
||||||
minAmount = item.incomeAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-item.expenseAmount < minAmount) {
|
|
||||||
minAmount = -item.expenseAmount;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `<table>` +
|
||||||
|
`<thead>` +
|
||||||
|
`<tr>` +
|
||||||
|
`<td colspan="2" class="text-left">${params[0].name}</td>` +
|
||||||
|
`</tr>` +
|
||||||
|
`</thead>` +
|
||||||
|
`<tbody>` +
|
||||||
|
(
|
||||||
|
incomeAmount !== null ?
|
||||||
|
`<tr>` +
|
||||||
|
`<td><span class="overview-monthly-chart-tooltip-indicator bg-income mr-1"></span><span class="mr-4">${tt('Income')}</span></td>` +
|
||||||
|
`<td><strong>${incomeAmount}</strong></td>` +
|
||||||
|
`</tr>` : ''
|
||||||
|
)+
|
||||||
|
(
|
||||||
|
expenseAmount !== null ?
|
||||||
|
`<tr>` +
|
||||||
|
`<td><span class="overview-monthly-chart-tooltip-indicator bg-expense mr-1"></span><span class="mr-4">${tt('Expense')}</span></td>` +
|
||||||
|
`<td><strong>${expenseAmount}</strong></td>` +
|
||||||
|
`</tr>` : ''
|
||||||
|
) +
|
||||||
|
`</tbody>` +
|
||||||
|
`</table>`;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const amountGap = maxAmount - minAmount;
|
legend: {
|
||||||
|
bottom: 20,
|
||||||
return {
|
itemWidth: 14,
|
||||||
tooltip: {
|
itemHeight: 14,
|
||||||
trigger: 'axis',
|
textStyle: {
|
||||||
axisPointer: {
|
color: props.isDarkMode ? '#eee' : '#333'
|
||||||
type: 'shadow'
|
},
|
||||||
},
|
icon: 'circle',
|
||||||
backgroundColor: self.isDarkMode ? '#333' : '#fff',
|
data: [ tt('Income'), tt('Expense') ]
|
||||||
borderColor: self.isDarkMode ? '#333' : '#fff',
|
},
|
||||||
textStyle: {
|
grid: {
|
||||||
color: self.isDarkMode ? '#eee' : '#333'
|
left: '20px',
|
||||||
},
|
right: '20px',
|
||||||
formatter: params => {
|
top: '10px',
|
||||||
let incomeAmount = 0;
|
bottom: '100px'
|
||||||
let expenseAmount = 0;
|
},
|
||||||
|
xAxis: [
|
||||||
for (let i = 0; i < params.length; i++) {
|
{
|
||||||
const param = params[i];
|
type: 'category',
|
||||||
const dataIndex = param.dataIndex;
|
data: monthNames,
|
||||||
const data = self.data[dataIndex];
|
axisLine: {
|
||||||
|
show: false
|
||||||
if (param.seriesId === 'seriesIncome') {
|
},
|
||||||
incomeAmount = self.getDisplayIncomeAmount(data);
|
axisTick: {
|
||||||
} else if (param.seriesId === 'seriesExpense') {
|
show: false
|
||||||
expenseAmount = self.getDisplayExpenseAmount(data);
|
},
|
||||||
}
|
axisLabel: {
|
||||||
}
|
padding: [ 20, 0, 0, 0 ]
|
||||||
|
}
|
||||||
return `<table>` +
|
}
|
||||||
`<thead>` +
|
],
|
||||||
`<tr>` +
|
yAxis: [
|
||||||
`<td colspan="2" class="text-left">${params[0].name}</td>` +
|
{
|
||||||
`</tr>` +
|
type: 'value',
|
||||||
`</thead>` +
|
min: minAmount - amountGap / 20,
|
||||||
`<tbody>` +
|
max: maxAmount,
|
||||||
`<tr>` +
|
splitNumber: 10,
|
||||||
`<td><span class="overview-monthly-chart-tooltip-indicator bg-income mr-1"></span><span class="mr-4">${self.$t('Income')}</span></td>` +
|
axisLabel: {
|
||||||
`<td><strong>${incomeAmount}</strong></td>` +
|
show: false
|
||||||
`</tr>` +
|
},
|
||||||
`<tr>` +
|
splitLine: {
|
||||||
`<td><span class="overview-monthly-chart-tooltip-indicator bg-expense mr-1"></span><span class="mr-4">${self.$t('Expense')}</span></td>` +
|
show: false
|
||||||
`<td><strong>${expenseAmount}</strong></td>` +
|
}
|
||||||
`</tr>` +
|
},
|
||||||
`</tbody>` +
|
{
|
||||||
`</table>`;
|
type: 'value',
|
||||||
|
min: minAmount,
|
||||||
|
max: maxAmount + amountGap / 20,
|
||||||
|
splitNumber: 10,
|
||||||
|
axisLabel: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
id: 'seriesIncome',
|
||||||
|
name: tt('Income'),
|
||||||
|
yAxisIndex: 0,
|
||||||
|
stack: 'Total',
|
||||||
|
itemStyle: {
|
||||||
|
color: expenseIncomeAmountColor.incomeAmountColor,
|
||||||
|
borderRadius: 16
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series',
|
||||||
|
labelLine: {
|
||||||
|
show: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
legend: {
|
barMaxWidth: 16,
|
||||||
bottom: 20,
|
data: incomeAmounts
|
||||||
itemWidth: 14,
|
},
|
||||||
itemHeight: 14,
|
{
|
||||||
textStyle: {
|
type: 'bar',
|
||||||
color: self.isDarkMode ? '#eee' : '#333'
|
id: 'seriesExpense',
|
||||||
},
|
name: tt('Expense'),
|
||||||
icon: 'circle',
|
yAxisIndex: 1,
|
||||||
data: [ self.$t('Income'), self.$t('Expense') ]
|
stack: 'Total',
|
||||||
|
itemStyle: {
|
||||||
|
color: expenseIncomeAmountColor.expenseAmountColor,
|
||||||
|
borderRadius: 16
|
||||||
},
|
},
|
||||||
grid: {
|
emphasis: {
|
||||||
left: '20px',
|
focus: 'series',
|
||||||
right: '20px',
|
labelLine: {
|
||||||
top: '10px',
|
show: false
|
||||||
bottom: '100px'
|
}
|
||||||
},
|
},
|
||||||
xAxis: [
|
barMaxWidth: 16,
|
||||||
{
|
data: expenseAmounts
|
||||||
type: 'category',
|
|
||||||
data: monthNames,
|
|
||||||
axisLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisTick: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
padding: [ 20, 0, 0, 0 ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
min: minAmount - amountGap / 20,
|
|
||||||
max: maxAmount,
|
|
||||||
splitNumber: 10,
|
|
||||||
axisLabel: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
min: minAmount,
|
|
||||||
max: maxAmount + amountGap / 20,
|
|
||||||
splitNumber: 10,
|
|
||||||
axisLabel: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
id: 'seriesIncome',
|
|
||||||
name: self.$t('Income'),
|
|
||||||
yAxisIndex: 0,
|
|
||||||
stack: 'Total',
|
|
||||||
itemStyle: {
|
|
||||||
color: expenseIncomeAmountColor.incomeAmountColor,
|
|
||||||
borderRadius: 16
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
focus: 'series',
|
|
||||||
labelLine: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 16,
|
|
||||||
data: incomeAmounts
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
id: 'seriesExpense',
|
|
||||||
name: self.$t('Expense'),
|
|
||||||
yAxisIndex: 1,
|
|
||||||
stack: 'Total',
|
|
||||||
itemStyle: {
|
|
||||||
color: expenseIncomeAmountColor.expenseAmountColor,
|
|
||||||
borderRadius: 16
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
focus: 'series',
|
|
||||||
labelLine: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 16,
|
|
||||||
data: expenseAmounts
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return {
|
|
||||||
globalTheme: theme
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
clickItem: function (e) {
|
|
||||||
if (!this.enableClickItem || !this.data || e.componentType !== 'series') {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const clickData = this.data[e.dataIndex];
|
function getDisplayCurrency(value: number | string, currencyCode: string): string {
|
||||||
|
return formatAmountWithCurrency(value, currencyCode) || '0';
|
||||||
|
}
|
||||||
|
|
||||||
if (clickData && e.seriesId === 'seriesIncome') {
|
function getDisplayAmount(amount: number, incomplete: boolean): string {
|
||||||
this.$emit('click', {
|
if (!showAmountInHomePage.value) {
|
||||||
transactionType: TransactionType.Income,
|
return getDisplayCurrency('***', defaultCurrency.value);
|
||||||
monthStartTime: clickData.monthStartTime
|
}
|
||||||
});
|
|
||||||
} else if (clickData && e.seriesId === 'seriesExpense') {
|
|
||||||
this.$emit('click', {
|
|
||||||
transactionType: TransactionType.Expense,
|
|
||||||
monthStartTime: clickData.monthStartTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getDisplayCurrency(value, currencyCode) {
|
|
||||||
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
|
|
||||||
},
|
|
||||||
getDisplayAmount(amount, incomplete) {
|
|
||||||
if (!this.showAmountInHomePage) {
|
|
||||||
return this.getDisplayCurrency('***', this.defaultCurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getDisplayCurrency(amount, this.defaultCurrency) + (incomplete ? '+' : '');
|
return getDisplayCurrency(amount, defaultCurrency.value) + (incomplete ? '+' : '');
|
||||||
},
|
}
|
||||||
getDisplayIncomeAmount(category) {
|
|
||||||
return this.getDisplayAmount(category.incomeAmount, category.incompleteIncomeAmount);
|
function getDisplayIncomeAmount(data: TransactionMonthlyIncomeAndExpenseData): string {
|
||||||
},
|
return getDisplayAmount(data.incomeAmount, data.incompleteIncomeAmount);
|
||||||
getDisplayExpenseAmount(category) {
|
}
|
||||||
return this.getDisplayAmount(category.expenseAmount, category.incompleteExpenseAmount);
|
|
||||||
}
|
function getDisplayExpenseAmount(data: TransactionMonthlyIncomeAndExpenseData): string {
|
||||||
|
return getDisplayAmount(data.expenseAmount, data.incompleteExpenseAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickItem(e: ECElementEvent): void {
|
||||||
|
if (!props.enableClickItem || !props.data || e.componentType !== 'series') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickData = props.data[e.dataIndex];
|
||||||
|
|
||||||
|
if (clickData && e.seriesId === 'seriesIncome') {
|
||||||
|
emit('click', {
|
||||||
|
transactionType: TransactionType.Income,
|
||||||
|
monthStartTime: clickData.monthStartTime
|
||||||
|
});
|
||||||
|
} else if (clickData && e.seriesId === 'seriesExpense') {
|
||||||
|
emit('click', {
|
||||||
|
transactionType: TransactionType.Expense,
|
||||||
|
monthStartTime: clickData.monthStartTime
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user