add transaction list page for desktop

This commit is contained in:
MaysWind
2023-07-24 02:36:59 +08:00
parent aafdbab781
commit 70fc781a03
13 changed files with 1227 additions and 19 deletions
+2 -2
View File
@@ -126,8 +126,8 @@ type TransactionListInMonthByPageRequest struct {
CategoryId int64 `form:"category_id" binding:"min=0"`
AccountId int64 `form:"account_id" binding:"min=0"`
Keyword string `form:"keyword"`
Page int32 `form:"page" binding:"required,min=1"`
Count int32 `form:"count" binding:"required,min=1,max=50"`
Page int32 `form:"page" binding:"min=0"`
Count int32 `form:"count" binding:"min=0,max=50"`
TrimAccount bool `form:"trim_account"`
TrimCategory bool `form:"trim_category"`
TrimTag bool `form:"trim_tag"`
+9 -3
View File
@@ -98,11 +98,11 @@ func (s *TransactionService) GetTransactionsInMonthByPage(uid int64, year int32,
return nil, errs.ErrUserIdInvalid
}
if page < 1 {
if page < 0 || (count > 0 && page < 1) {
return nil, errs.ErrPageIndexInvalid
}
if count < 1 {
if count < 0 {
return nil, errs.ErrPageCountInvalid
}
@@ -120,7 +120,13 @@ func (s *TransactionService) GetTransactionsInMonthByPage(uid int64, year int32,
var transactions []*models.Transaction
condition, conditionParams := s.getTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, keyword, true)
err = s.UserDataDB(uid).Where(condition, conditionParams...).Limit(int(count), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions)
sess := s.UserDataDB(uid).Where(condition, conditionParams...)
if count > 0 {
sess = sess.Limit(int(count), int(count*(page-1)))
}
err = sess.OrderBy("transaction_time desc").Find(&transactions)
return transactions, err
}
+64
View File
@@ -331,6 +331,70 @@ export function getDateRangeByDateType(dateType, firstDayOfWeek) {
};
}
export function getRecentMonthDateRanges(monthCount) {
const recentDateRanges = [];
const thisMonthFirstUnixTime = getThisMonthFirstUnixTime();
for (let i = 0; i < monthCount; i++) {
let minTime = thisMonthFirstUnixTime;
if (i > 0) {
minTime = getUnixTimeBeforeUnixTime(thisMonthFirstUnixTime, i, 'months');
}
let maxTime = getUnixTimeBeforeUnixTime(getUnixTimeAfterUnixTime(minTime, 1, 'months'), 1, 'seconds');
let dateType = dateTimeConstants.allDateRanges.Custom.type;
let year = getYear(parseDateFromUnixTime(minTime));
let month = getMonth(parseDateFromUnixTime(minTime));
if (i === 0) {
dateType = dateTimeConstants.allDateRanges.ThisMonth.type;
} else if (i === 1) {
dateType = dateTimeConstants.allDateRanges.LastMonth.type;
}
recentDateRanges.push({
dateType: dateType,
minTime: minTime,
maxTime: maxTime,
year: year,
month: month
});
}
return recentDateRanges;
}
export function getRecentDateRangeType(allRecentMonthDateRanges, dateType, minTime, maxTime, firstDayOfWeek) {
let dateRange = getDateRangeByDateType(dateType, firstDayOfWeek);
if (dateRange && dateRange.dateType === dateTimeConstants.allDateRanges.All.type) {
return allRecentMonthDateRanges.length - 1; // Custom
}
if (!dateRange && (!maxTime || !minTime)) {
return allRecentMonthDateRanges.length - 1; // Custom
}
if (!dateRange) {
dateRange = {
dateType: dateTimeConstants.allDateRanges.Custom.type,
maxTime: maxTime,
minTime: minTime
};
}
for (let i = 0; i < allRecentMonthDateRanges.length - 1; i++) {
const recentDateRange = allRecentMonthDateRanges[i];
if (recentDateRange.minTime === dateRange.minTime && recentDateRange.maxTime === dateRange.maxTime) {
return i;
}
}
return allRecentMonthDateRanges.length - 1; // Custom
}
export function isDateRangeMatchFullYears(minTime, maxTime) {
const minDateTime = parseDateFromUnixTime(minTime).set({ second: 0, millisecond: 0 });
const maxDateTime = parseDateFromUnixTime(maxTime).set({ second: 59, millisecond: 999 });
+41
View File
@@ -22,6 +22,7 @@ import {
getBrowserTimezoneOffset,
getBrowserTimezoneOffsetMinutes,
getDateTimeFormatType,
getRecentMonthDateRanges,
isDateRangeMatchFullYears,
isDateRangeMatchFullMonths
} from './datetime.js';
@@ -388,10 +389,18 @@ function getMonthShortName(month, translateFn) {
return translateFn(`datetime.${month}.short`);
}
function getMonthLongName(month, translateFn) {
return translateFn(`datetime.${month}.long`);
}
function getWeekdayShortName(weekDay, translateFn) {
return translateFn(`datetime.${weekDay}.short`);
}
function getWeekdayLongName(weekDay, translateFn) {
return translateFn(`datetime.${weekDay}.long`);
}
function getI18nLongDateFormat(translateFn, formatTypeValue) {
const defaultLongDateFormatTypeName = translateFn('default.longDateFormat');
return getDateTimeFormat(translateFn, datetime.allLongDateFormat, datetime.allLongDateFormatArray, 'format.longDate', defaultLongDateFormatTypeName, datetime.defaultLongDateFormat, formatTypeValue);
@@ -588,6 +597,35 @@ function getAllDateRanges(includeCustom, translateFn) {
return allDateRanges;
}
function getAllRecentMonthDateRanges(userStore, includeCustom, translateFn) {
const allRecentMonthDateRanges = [];
const recentDateRanges = getRecentMonthDateRanges(12);
for (let i = 0; i < recentDateRanges.length; i++) {
const recentDateRange = recentDateRanges[i];
allRecentMonthDateRanges.push({
dateType: recentDateRange.dateType,
minTime: recentDateRange.minTime,
maxTime: recentDateRange.maxTime,
year: recentDateRange.year,
month: recentDateRange.month,
displayName: formatUnixTime(recentDateRange.minTime, getI18nLongYearMonthFormat(translateFn, userStore.currentUserLongDateFormat))
});
}
if (includeCustom) {
allRecentMonthDateRanges.push({
dateType: datetime.allDateRanges.Custom.type,
minTime: 0,
maxTime: 0,
displayName: translateFn('Custom Date')
});
}
return allRecentMonthDateRanges;
}
function getDateRangeDisplayName(userStore, dateType, startTime, endTime, translateFn) {
if (dateType === datetime.allDateRanges.All.type) {
return translateFn(datetime.allDateRanges.All.name);
@@ -993,7 +1031,9 @@ export function i18nFunctions(i18nGlobal) {
getAllLongTimeFormats: () => getAllLongTimeFormats(i18nGlobal.t),
getAllShortTimeFormats: () => getAllShortTimeFormats(i18nGlobal.t),
getMonthShortName: (month) => getMonthShortName(month, i18nGlobal.t),
getMonthLongName: (month) => getMonthLongName(month, i18nGlobal.t),
getWeekdayShortName: (weekDay) => getWeekdayShortName(weekDay, i18nGlobal.t),
getWeekdayLongName: (weekDay) => getWeekdayLongName(weekDay, i18nGlobal.t),
formatUnixTimeToLongDateTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nLongDateFormat(i18nGlobal.t, userStore.currentUserLongDateFormat) + ' ' + getI18nLongTimeFormat(i18nGlobal.t, userStore.currentUserLongTimeFormat), utcOffset, currentUtcOffset),
formatUnixTimeToShortDateTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nShortDateFormat(i18nGlobal.t, userStore.currentUserShortDateFormat) + ' ' + getI18nShortTimeFormat(i18nGlobal.t, userStore.currentUserShortTimeFormat), utcOffset, currentUtcOffset),
formatUnixTimeToLongDate: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nLongDateFormat(i18nGlobal.t, userStore.currentUserLongDateFormat), utcOffset, currentUtcOffset),
@@ -1014,6 +1054,7 @@ export function i18nFunctions(i18nGlobal) {
getAllCurrencies: () => getAllCurrencies(i18nGlobal.t),
getAllWeekDays: () => getAllWeekDays(i18nGlobal.t),
getAllDateRanges: (includeCustom) => getAllDateRanges(includeCustom, i18nGlobal.t),
getAllRecentMonthDateRanges: (userStore, includeCustom) => getAllRecentMonthDateRanges(userStore, includeCustom, i18nGlobal.t),
getDateRangeDisplayName: (userStore, dateType, startTime, endTime) => getDateRangeDisplayName(userStore, dateType, startTime, endTime, i18nGlobal.t),
getAllStatisticsChartDataTypes: () => getAllStatisticsChartDataTypes(i18nGlobal.t),
getAllStatisticsSortingTypes: () => getAllStatisticsSortingTypes(i18nGlobal.t),
+4 -1
View File
@@ -13,7 +13,7 @@ let needBlockRequest = false;
let blockedRequests = [];
axios.defaults.baseURL = api.baseApiUrlPath;
axios.defaults.timeout = 10000; // 10s
axios.defaults.timeout = 100000; // 10s
axios.interceptors.request.use(config => {
const token = userState.getToken();
@@ -240,6 +240,9 @@ export default {
getTransactions: ({ maxTime, minTime, type, categoryId, accountId, keyword }) => {
return axios.get(`v1/transactions/list.json?max_time=${maxTime}&min_time=${minTime}&type=${type}&category_id=${categoryId}&account_id=${accountId}&keyword=${keyword}&count=50&trim_account=true&trim_category=true&trim_tag=true`);
},
getAllTransactionsByMonth: ({ year, month, type, categoryId, accountId, keyword }) => {
return axios.get(`v1/transactions/list/by_month.json?year=${year}&month=${month}&type=${type}&category_id=${categoryId}&account_id=${accountId}&keyword=${keyword}&trim_account=true&trim_category=true&trim_tag=true`);
},
getTransactionStatistics: ({ startTime, endTime }) => {
const queryParams = [];
+52
View File
@@ -0,0 +1,52 @@
export function getOuterHeight(element) {
if (!element) {
return 0;
}
const computedStyle = window.getComputedStyle(element);
return ['height', 'padding-top', 'padding-bottom', 'margin-top', 'margin-bottom']
.map((key) => parseInt(computedStyle.getPropertyValue(key), 10))
.reduce((prev, cur) => prev + cur);
}
export function getCssValue(element, name) {
if (!element) {
return 0;
}
const computedStyle = window.getComputedStyle(element);
return computedStyle.getPropertyValue(name);
}
export function scrollToMenuListItem(listContentEl) {
if (!listContentEl) {
return;
}
const lists = listContentEl.querySelectorAll('div.v-list');
if (!lists.length || !lists[0]) {
return;
}
const container = lists[0];
const selectedItems = container.querySelectorAll('div.v-list-item.list-item-selected');
if (!selectedItems.length || !selectedItems[0]) {
return;
}
const selectedItem = selectedItems[0];
const containerOuterHeight = getOuterHeight(container);
const selectedItemOuterHeight = getOuterHeight(selectedItem);
const targetPos = selectedItem.offsetTop - container.offsetTop - parseInt(getCssValue(container, 'padding-top'), 10)
- (containerOuterHeight - selectedItemOuterHeight) / 2;
if (targetPos <= 0) {
return;
}
container.scrollTop = targetPos;
}
+4
View File
@@ -730,7 +730,9 @@ export default {
'Duplicate': 'Duplicate',
'Sort': 'Sort',
'Date': 'Date',
'Time': 'Time',
'Type': 'Type',
'All Types': 'All Types',
'More': 'More',
'All': 'All',
'Today': 'Today',
@@ -862,6 +864,7 @@ export default {
'Unable to get account list': 'Unable to get account list',
'Account list is up to date': 'Account list is up to date',
'Account list has been updated': 'Account list has been updated',
'All Accounts': 'All Accounts',
'No available account': 'No available account',
'Add Account': 'Add Account',
'Edit Account': 'Edit Account',
@@ -1071,6 +1074,7 @@ export default {
'Income Secondary Categories': 'Income Secondary Categories',
'Transfer Secondary Categories': 'Transfer Secondary Categories',
'Transaction Secondary Categories': 'Transaction Secondary Categories',
'All Categories': 'All Categories',
'No available category': 'No available category',
'Add Default Categories': 'Add Default Categories',
'Default Categories': 'Default Categories',
+4
View File
@@ -730,7 +730,9 @@ export default {
'Duplicate': '复制',
'Sort': '排序',
'Date': '日期',
'Time': '时间',
'Type': '类型',
'All Types': '全部类型',
'More': '更多',
'All': '全部',
'Today': '今天',
@@ -862,6 +864,7 @@ export default {
'Unable to get account list': '无法获取账户列表',
'Account list is up to date': '账户列表已是最新',
'Account list has been updated': '账户列表已更新',
'All Accounts': '全部账户',
'No available account': '没有可用的账户',
'Add Account': '添加账户',
'Edit Account': '编辑账户',
@@ -1071,6 +1074,7 @@ export default {
'Income Secondary Categories': '收入二级分类',
'Transfer Secondary Categories': '转账二级分类',
'Transaction Secondary Categories': '交易二级分类',
'All Categories': '全部分类',
'No available category': '没有可用的分类',
'Add Default Categories': '添加默认分类',
'Default Categories': '默认分类',
+9 -1
View File
@@ -82,7 +82,15 @@ const router = createRouter({
{
path: '/transactions',
component: TransactionsPage,
beforeEnter: checkLogin
beforeEnter: checkLogin,
props: route => ({
initDateType: route.query.dateType,
initMaxTime: route.query.maxTime,
initMinTime: route.query.minTime,
initType: route.query.type,
initCategoryId: route.query.categoryId,
initAccountId: route.query.accountId
})
},
{
path: '/statistics/transaction',
+42 -3
View File
@@ -377,7 +377,31 @@ export const useTransactionsStore = defineStore('transactions', {
this.transactionsFilter.keyword = filter.keyword;
}
},
loadTransactions({ reload, autoExpand, defaultCurrency }) {
getTransactionListPageParams() {
const querys = [];
if (this.transactionsFilter.type) {
querys.push('type=' + this.transactionsFilter.type);
}
if (this.transactionsFilter.accountId && this.transactionsFilter.accountId !== '0') {
querys.push('accountId=' + this.transactionsFilter.accountId);
}
if (this.transactionsFilter.categoryId && this.transactionsFilter.categoryId !== '0') {
querys.push('categoryId=' + this.transactionsFilter.categoryId);
}
querys.push('dateType=' + this.transactionsFilter.dateType);
if (this.transactionsFilter.dateType === datetimeConstants.allDateRanges.Custom.type) {
querys.push('maxTime=' + this.transactionsFilter.maxTime);
querys.push('minTime=' + this.transactionsFilter.minTime);
}
return querys.join('&');
},
loadTransactions({ reload, yearMonth, autoExpand, defaultCurrency }) {
const self = this;
const settingsStore = useSettingsStore();
const exchangeRatesStore = useExchangeRatesStore();
@@ -390,14 +414,29 @@ export const useTransactionsStore = defineStore('transactions', {
}
return new Promise((resolve, reject) => {
services.getTransactions({
let promise = null;
if (yearMonth) {
promise = services.getAllTransactionsByMonth({
year: yearMonth.year,
month: yearMonth.month,
type: self.transactionsFilter.type,
categoryId: self.transactionsFilter.categoryId,
accountId: self.transactionsFilter.accountId,
keyword: self.transactionsFilter.keyword
});
} else {
promise = services.getTransactions({
maxTime: actualMaxTime,
minTime: self.transactionsFilter.minTime * 1000,
type: self.transactionsFilter.type,
categoryId: self.transactionsFilter.categoryId,
accountId: self.transactionsFilter.accountId,
keyword: self.transactionsFilter.keyword
}).then(response => {
});
}
promise.then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
+10 -4
View File
@@ -113,16 +113,22 @@ input[type=number] {
tr.even-row {
background: #fcfcfc;
}
tbody.has-bottom-border > tr:last-child > td {
border-bottom: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.v-table.v-table--hover {
tbody > tr.no-hover:hover td {
background: inherit !important;
}
}
.v-table.table-striped {
tr:nth-child(even) {
background: #fcfcfc;
}
tbody.has-bottom-border > tr:last-child > td {
border-bottom: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
.v-theme--dark {
+1 -1
View File
@@ -27,7 +27,7 @@
</div>
</li>
<li class="nav-link">
<router-link to="/transactions">
<router-link to="/transactions?dateType=7">
<v-icon class="nav-item-icon" :icon="icons.transactions"/>
<span class="nav-item-title">{{ $t('Transaction List') }}</span>
</router-link>
+985 -4
View File
@@ -1,13 +1,994 @@
<template>
<v-row class="match-height">
transactions
<v-col cols="12">
<v-card>
<div class="d-flex flex-column flex-md-row">
<div>
<div class="mx-6 my-4">
<div class="transaction-type-buttons d-flex flex-column">
<v-btn border :color="query.type === 0 ? 'primary' : 'default'"
:variant="query.type === 0 ? 'tonal' : 'outlined'" :disabled="loading"
@click="changeTypeFilter(0)">
{{ $t('All Types') }}
</v-btn>
<v-btn border :color="query.type === 1 ? 'primary' : 'default'"
:variant="query.type === 1 ? 'tonal' : 'outlined'" :disabled="loading"
@click="changeTypeFilter(1)">
{{ $t('Modify Balance') }}
</v-btn>
<v-btn border :color="query.type === 2 ? 'primary' : 'default'"
:variant="query.type === 2 ? 'tonal' : 'outlined'" :disabled="loading"
@click="changeTypeFilter(2)">
{{ $t('Income') }}
</v-btn>
<v-btn border :color="query.type === 3 ? 'primary' : 'default'"
:variant="query.type === 3 ? 'tonal' : 'outlined'" :disabled="loading"
@click="changeTypeFilter(3)">
{{ $t('Expense') }}
</v-btn>
<v-btn border :color="query.type === 4 ? 'primary' : 'default'"
:variant="query.type === 4 ? 'tonal' : 'outlined'" :disabled="loading"
@click="changeTypeFilter(4)">
{{ $t('Transfer') }}
</v-btn>
</div>
</div>
<v-divider />
<v-tabs show-arrows class="my-4" direction="vertical"
:disabled="loading" v-model="recentDateRangeType">
<v-tab :key="idx" :value="idx" v-for="(recentDateRange, idx) in recentMonthDateRanges"
@click="changeDateFilter(recentDateRange)">
{{ recentDateRange.displayName }}
</v-tab>
</v-tabs>
</div>
<v-window class="d-flex flex-grow-1 ml-md-5 disable-tab-transition w-100-window-container" v-model="activeTab">
<v-window-item value="transactionPage">
<v-card variant="flat">
<template #title>
<div class="transaction-list-title d-flex align-center text-no-wrap">
<span>{{ $t('Transaction List') }}</span>
<v-btn class="ml-3" color="default" variant="outlined"
:disabled="loading" @click="add">{{ $t('Add') }}</v-btn>
<v-btn density="compact" color="default" variant="text"
class="ml-2" :icon="true" :disabled="loading"
v-if="!loading" @click="reload">
<v-icon :icon="icons.refresh" size="24" />
<v-tooltip activator="parent">{{ $t('Refresh') }}</v-tooltip>
</v-btn>
<v-progress-circular indeterminate size="24" class="ml-2" v-if="loading"></v-progress-circular>
<v-spacer/>
<div class="transaction-keyword-filter ml-2">
<v-text-field density="compact" :disabled="loading"
:prepend-inner-icon="icons.search"
:append-inner-icon="searchKeyword !== query.keyword ? icons.check : null"
:placeholder="$t('Search transaction description')"
v-model="searchKeyword"
@click:append-inner="changeKeywordFilter(searchKeyword)"
@keyup.enter="changeKeywordFilter(searchKeyword)"
/>
</div>
</div>
</template>
<v-card-text class="pt-0">
<div class="transaction-list-datetime-range d-flex align-center">
<span class="text-body-1">{{ $t('Date Range') }}</span>
<span class="text-body-1 transaction-list-datetime-range-text ml-2">
<span v-if="!this.query.minTime && !this.query.maxTime">{{ $t('All') }}</span>
<span v-if="this.query.minTime || this.query.maxTime">{{ queryMinTime }}</span>
<span v-if="this.query.minTime || this.query.maxTime">&nbsp;-&nbsp;</span>
<span v-if="this.query.minTime || this.query.maxTime">{{ queryMaxTime }}</span>
</span>
<v-spacer/>
<div class="transaction-list-total-amount-text d-flex align-center" v-if="isShowMonthlyData">
<span class="ml-2 text-subtitle-1">{{ $t('Total Income') }}</span>
<span class="text-income ml-2" v-if="loading">
<v-skeleton-loader type="text" style="width: 60px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-income ml-2" v-else-if="!loading">
{{ monthlyDataTotalIncome }}
</span>
<span class="text-subtitle-1 ml-2">{{ $t('Total Expense') }}</span>
<span class="text-income ml-2" v-if="loading">
<v-skeleton-loader type="text" style="width: 60px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-expense ml-2" v-else-if="!loading">
{{ monthlyDataTotalExpense }}
</span>
</div>
</div>
</v-card-text>
<v-table class="transaction-table" :hover="!loading">
<thead>
<tr>
<th class="transaction-table-column-time text-uppercase">{{ $t('Time') }}</th>
<th class="transaction-table-column-category text-uppercase">
<v-menu ref="categoryFilterMenu" class="transaction-category-menu"
eager location="bottom" max-height="500"
:close-on-content-click="false"
@update:model-value="scrollCategoryMenuToSelectedItem">
<template #activator="{ props }">
<div class="cursor-pointer"
:class="{ 'readonly': loading, 'text-primary': query.categoryId > 0 }" v-bind="props">
<span>{{ queryCategoryName }}</span>
<v-icon :icon="icons.dropdownMenu" />
</div>
</template>
<v-list :selected="[query.categoryId]">
<v-list-item key="0" value="0" class="text-sm" density="compact"
:class="{ 'list-item-selected': query.categoryId === '0' }"
:append-icon="(query.categoryId === '0' ? icons.check : null)">
<v-list-item-title class="cursor-pointer"
@click="changeCategoryFilter('0')">
<div class="d-flex align-center">
<v-icon :icon="icons.all" />
<span class="text-sm ml-3">{{ $t('All') }}</span>
</div>
</v-list-item-title>
</v-list-item>
<template :key="categoryType"
v-for="(categories, categoryType) in allPrimaryCategories"
v-show="!query.type || getTransactionTypeFromCategoryType(categoryType) === query.type">
<v-list-item density="compact">
<v-list-item-title>
<span class="text-sm">{{ getTransactionTypeName(getTransactionTypeFromCategoryType(categoryType), 'Type') }}</span>
</v-list-item-title>
</v-list-item>
<v-list-group :key="category.id" v-for="category in categories">
<template v-slot:activator="{ props }" v-if="!category.hidden">
<v-divider />
<v-list-item class="text-sm" density="compact"
:class="getCategoryListItemCheckedClass(category, query.categoryId)"
v-bind="props">
<v-list-item-title>
<div class="d-flex align-center">
<ItemIcon icon-type="category" size="24px" :icon-id="category.icon" :color="category.color"></ItemIcon>
<span class="text-sm ml-3">{{ category.name }}</span>
</div>
</v-list-item-title>
</v-list-item>
</template>
<v-divider />
<v-list-item class="text-sm" density="compact"
:value="category.id"
:append-icon="(query.categoryId === category.id ? icons.check : null)">
<v-list-item-title class="cursor-pointer"
@click="changeCategoryFilter(category.id)">
<div class="d-flex align-center">
<v-icon :icon="icons.all" />
<span class="text-sm ml-3">{{ $t('All') }}</span>
</div>
</v-list-item-title>
</v-list-item>
<template :key="subCategory.id"
v-for="subCategory in category.subCategories">
<v-divider v-if="!subCategory.hidden" />
<v-list-item class="text-sm" density="compact"
:value="subCategory.id"
:class="{ 'list-item-selected': query.categoryId === subCategory.id }"
:append-icon="(query.categoryId === subCategory.id ? icons.check : null)"
v-if="!subCategory.hidden">
<v-list-item-title class="cursor-pointer"
@click="changeCategoryFilter(subCategory.id)">
<div class="d-flex align-center">
<ItemIcon icon-type="category" size="24px" :icon-id="subCategory.icon" :color="subCategory.color"></ItemIcon>
<span class="text-sm ml-3">{{ subCategory.name }}</span>
</div>
</v-list-item-title>
</v-list-item>
</template>
</v-list-group>
</template>
</v-list>
</v-menu>
</th>
<th class="transaction-table-column-amount text-uppercase">{{ $t('Amount') }}</th>
<th class="transaction-table-column-account text-uppercase">
<v-menu ref="accountFilterMenu" class="transaction-account-menu"
eager location="bottom" max-height="500"
@update:model-value="scrollAccountMenuToSelectedItem">
<template #activator="{ props }">
<div class="cursor-pointer"
:class="{ 'readonly': loading, 'text-primary': query.accountId > 0 }" v-bind="props">
<span>{{ queryAccountName }}</span>
<v-icon :icon="icons.dropdownMenu" />
</div>
</template>
<v-list :selected="[query.accountId]">
<v-list-item key="0" value="0" class="text-sm" density="compact"
:class="{ 'list-item-selected': query.accountId === '0' }"
:append-icon="(query.accountId === '0' ? icons.check : null)">
<v-list-item-title class="cursor-pointer"
@click="changeAccountFilter('0')">
<div class="d-flex align-center">
<v-icon :icon="icons.all" />
<span class="text-sm ml-3">{{ $t('All') }}</span>
</div>
</v-list-item-title>
</v-list-item>
<template :key="account.id"
v-for="account in allAccounts">
<v-divider v-if="!account.hidden" />
<v-list-item class="text-sm" density="compact"
:value="account.id"
:class="{ 'list-item-selected': query.accountId === account.id }"
:append-icon="(query.accountId === account.id ? icons.check : null)"
v-if="!account.hidden">
<v-list-item-title class="cursor-pointer"
@click="changeAccountFilter(account.id)">
<div class="d-flex align-center">
<ItemIcon icon-type="account" size="24px" :icon-id="account.icon" :color="account.color"></ItemIcon>
<span class="text-sm ml-3">{{ account.name }}</span>
</div>
</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
</th>
<th class="transaction-table-column-description text-uppercase">{{ $t('Description') }}</th>
</tr>
</thead>
<tbody v-if="loading">
<tr :key="itemIdx" v-for="itemIdx in [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]">
<td class="px-0" colspan="5">
<v-skeleton-loader type="text" :loading="true"></v-skeleton-loader>
</td>
</tr>
</tbody>
<tbody v-if="!loading && noTransaction">
<tr>
<td colspan="5">{{ $t('No transaction data') }}</td>
</tr>
</tbody>
<template :key="transactionMonthList.yearMonth" :class="{ 'has-bottom-border': monthIdx < transactions.length - 1 }"
v-for="(transactionMonthList, monthIdx) in transactions">
<tbody v-if="shouldShowMonthlyData(transactionMonthList)">
<template :key="transaction.id" v-for="(transaction, idx) in transactionMonthList.items">
<template v-if="monthlyDatePageFirstIndex <= idx && idx < monthlyDatePageLastIndex">
<tr class="transaction-list-row-date no-hover text-sm"
v-if="idx === 0 || monthlyDatePageFirstIndex === idx || (idx > 0 && (transaction.day !== transactionMonthList.items[idx - 1].day))">
<td colspan="5" class="font-weight-bold">
<div class="d-flex align-center">
<span>{{ getLongDate(transaction) }}</span>
<v-chip class="ml-1" color="default" size="x-small">
{{ getWeekdayLongName(transaction) }}
</v-chip>
</div>
</td>
</tr>
<tr class="transaction-table-row-data text-sm cursor-pointer"
@click="show(transaction)">
<td class="transaction-table-column-time">
<div class="d-flex flex-column">
<span>{{ getDisplayTime(transaction) }}</span>
<span class="text-caption" v-if="transaction.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(transaction) }}</span>
</div>
</td>
<td class="transaction-table-column-category">
<div class="d-flex align-center">
<ItemIcon size="24px" icon-type="category"
:icon-id="transaction.category.icon"
:color="transaction.category.color"
v-if="transaction.category && transaction.category.color"></ItemIcon>
<v-icon size="24" :icon="icons.modifyBalance" v-else-if="!transaction.category || !transaction.category.color" />
<span class="ml-2" v-if="transaction.type === allTransactionTypes.ModifyBalance">
{{ $t('Modify Balance') }}
</span>
<span class="ml-2" v-else-if="transaction.type !== allTransactionTypes.ModifyBalance && transaction.category">
{{ transaction.category.name }}
</span>
<span class="ml-2" v-else-if="transaction.type !== allTransactionTypes.ModifyBalance && !transaction.category">
{{ getTransactionTypeName(transaction.type, 'Transaction') }}
</span>
</div>
</td>
<td class="transaction-table-column-amount" :class="{ 'text-expense': transaction.type === allTransactionTypes.Expense, 'text-income': transaction.type === allTransactionTypes.Income }">
<div v-if="transaction.sourceAccount">
<span v-if="!query.accountId || query.accountId === '0' || (transaction.sourceAccount && (transaction.sourceAccount.id === query.accountId || transaction.sourceAccount.parentId === query.accountId))">{{ getDisplayAmount(transaction.sourceAmount, transaction.sourceAccount.currency, transaction.hideAmount) }}</span>
<span v-else-if="query.accountId && query.accountId !== '0' && transaction.destinationAccount && (transaction.destinationAccount.id === query.accountId || transaction.destinationAccount.parentId === query.accountId)">{{ getDisplayAmount(transaction.destinationAmount, transaction.destinationAccount.currency, transaction.hideAmount) }}</span>
<span v-else></span>
</div>
</td>
<td class="transaction-table-column-account">
<span v-if="transaction.sourceAccount">{{ transaction.sourceAccount.name }}</span>
<v-icon :icon="icons.arrowRight" v-if="transaction.sourceAccount && transaction.type === allTransactionTypes.Transfer && transaction.destinationAccount && transaction.sourceAccount.id !== transaction.destinationAccount.id"></v-icon>
<span v-if="transaction.sourceAccount && transaction.type === allTransactionTypes.Transfer && transaction.destinationAccount && transaction.sourceAccount.id !== transaction.destinationAccount.id">{{ transaction.destinationAccount.name }}</span>
</td>
<td class="transaction-table-column-description">
<div class="d-flex align-center">
<span>{{ transaction.comment }}</span>
</div>
</td>
</tr>
</template>
</template>
</tbody>
</template>
</v-table>
<div class="mt-2 mb-4" v-if="isShowMonthlyData">
<v-pagination :total-visible="5" :length="monthlyDatePageCount"
v-model="currentPage"></v-pagination>
</div>
</v-card>
</v-window-item>
</v-window>
</div>
</v-card>
</v-col>
</v-row>
<date-range-selection-dialog :title="$t('Custom Date Range')" :persistent="true"
:min-time="query.minTime"
:max-time="query.maxTime"
v-model:show="showCustomDateRangeDialog"
@dateRange:change="changeCustomDateFilter" />
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" />
</template>
<script>
export default {
created() {
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useAccountsStore } from '@/stores/account.js';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js';
import { useTransactionsStore } from '@/stores/transaction.js';
import datetimeConstants from '@/consts/datetime.js';
import currencyConstants from '@/consts/currency.js';
import accountConstants from '@/consts/account.js';
import transactionConstants from '@/consts/transaction.js';
import { getNameByKeyValue } from '@/lib/common.js';
import {
getCurrentUnixTime,
parseDateFromUnixTime,
getUnixTime,
getSpecifiedDayFirstUnixTime,
getUtcOffsetByUtcOffsetMinutes,
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getActualUnixTimeForStore,
getDateRangeByDateType,
getRecentDateRangeType
} from '@/lib/datetime.js';
import {
categoryTypeToTransactionType,
transactionTypeToCategoryType
} from '@/lib/category.js';
import { scrollToMenuListItem } from '@/lib/ui.desktop.js';
import {
mdiMagnify,
mdiCheck,
mdiTextBoxCheckOutline,
mdiRefresh,
mdiMenuDown,
mdiPencilBoxOutline,
mdiArrowRightThin,
mdiDeleteOutline,
mdiDotsVertical
} from '@mdi/js';
export default {
props: [
'initDateType',
'initMaxTime',
'initMinTime',
'initType',
'initCategoryId',
'initAccountId'
],
data() {
return {
loading: true,
updating: false,
activeTab: 'transactionPage',
currentPage: 1,
countPerPage: 15,
searchKeyword: '',
showCustomDateRangeDialog: false,
transactionRemoving: {},
icons: {
search: mdiMagnify,
check: mdiCheck,
all: mdiTextBoxCheckOutline,
refresh: mdiRefresh,
dropdownMenu: mdiMenuDown,
modifyBalance: mdiPencilBoxOutline,
arrowRight: mdiArrowRightThin,
remove: mdiDeleteOutline,
more: mdiDotsVertical
}
};
},
computed: {
...mapStores(useSettingsStore, useUserStore, useAccountsStore, useTransactionCategoriesStore, useTransactionsStore),
defaultCurrency() {
if (this.query.accountId && this.query.accountId !== '0') {
const account = this.allAccounts[this.query.accountId];
if (account && account.currency && account.currency !== currencyConstants.parentAccountCurrencyPlaceholder) {
return account.currency;
}
}
return this.userStore.currentUserDefaultCurrency;
},
canAddTransaction() {
if (this.query.accountId && this.query.accountId !== '0') {
const account = this.allAccounts[this.query.accountId];
if (account && account.type === accountConstants.allAccountTypes.MultiSubAccounts) {
return false;
}
}
return true;
},
currentTimezoneOffsetMinutes() {
return getTimezoneOffsetMinutes(this.settingsStore.appSettings.timeZone);
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
recentDateRangeType: {
get: function () {
return getRecentDateRangeType(this.recentMonthDateRanges, this.query.dateType, this.query.minTime, this.query.maxTime, this.firstDayOfWeek);
},
set: function (value) {
if (value < 0 || value >= this.recentMonthDateRanges.length) {
value = 0;
}
this.changeDateFilter(this.recentMonthDateRanges[value]);
}
},
query() {
return this.transactionsStore.transactionsFilter;
},
queryMinTime() {
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, this.query.minTime);
},
queryMaxTime() {
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, this.query.maxTime);
},
queryCategoryName() {
return getNameByKeyValue(this.allCategories, this.query.categoryId, null, 'name', this.$t('Category'));
},
queryAccountName() {
return getNameByKeyValue(this.allAccounts, this.query.accountId, null, 'name', this.$t('Account'));
},
transactions() {
if (this.loading) {
return [];
}
return this.transactionsStore.transactions;
},
noTransaction() {
return this.transactionsStore.noTransaction;
},
hasMoreTransaction() {
return this.transactionsStore.hasMoreTransaction;
},
isShowMonthlyData() {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
return recentDateRange.year && recentDateRange.month;
},
monthlyDatePageCount() {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
if (!recentDateRange || !recentDateRange.year || !recentDateRange.month) {
return 1;
}
if (!this.transactions || !this.transactions.length) {
return 1;
}
for (let i = 0; i < this.transactions.length; i++) {
if (this.transactions[i].year === recentDateRange.year &&
this.transactions[i].month === recentDateRange.month) {
return Math.ceil(this.transactions[i].items.length / this.countPerPage);
}
}
return 1;
},
monthlyDatePageFirstIndex() {
const currentPage = this.currentPage >= 1 ? this.currentPage : 1;
if (this.isShowMonthlyData) {
return (currentPage - 1) * this.countPerPage;
} else {
return 0;
}
},
monthlyDatePageLastIndex() {
const currentPage = this.currentPage >= 1 ? this.currentPage : 1;
if (this.isShowMonthlyData) {
return currentPage * this.countPerPage;
} else {
return Number.MAX_SAFE_INTEGER;
}
},
monthlyDataTotalIncome() {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
if (!recentDateRange || !recentDateRange.year || !recentDateRange.month) {
return '-';
}
if (!this.transactions || !this.transactions.length) {
return '-';
}
for (let i = 0; i < this.transactions.length; i++) {
if (this.transactions[i].year === recentDateRange.year &&
this.transactions[i].month === recentDateRange.month) {
return this.getDisplayMonthTotalAmount(this.transactions[i].totalAmount.income, this.defaultCurrency, '', this.transactions[i].totalAmount.incompleteIncome)
}
}
return '-';
},
monthlyDataTotalExpense() {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
if (!recentDateRange || !recentDateRange.year || !recentDateRange.month) {
return '-';
}
if (!this.transactions || !this.transactions.length) {
return '-';
}
for (let i = 0; i < this.transactions.length; i++) {
if (this.transactions[i].year === recentDateRange.year &&
this.transactions[i].month === recentDateRange.month) {
return this.getDisplayMonthTotalAmount(this.transactions[i].totalAmount.expense, this.defaultCurrency, '', this.transactions[i].totalAmount.incompleteExpense)
}
}
return '-';
},
allTransactionTypes() {
return transactionConstants.allTransactionTypes;
},
allAccounts() {
return this.accountsStore.allAccountsMap;
},
allCategories() {
return this.transactionCategoriesStore.allTransactionCategoriesMap;
},
allPrimaryCategories() {
return this.transactionCategoriesStore.allTransactionCategories;
},
recentMonthDateRanges() {
return this.$locale.getAllRecentMonthDateRanges(this.userStore, true);
},
showTotalAmountInTransactionListPage() {
return this.settingsStore.appSettings.showTotalAmountInTransactionListPage;
}
},
created() {
this.init({
dateType: this.initDateType,
minTime: this.initMinTime,
maxTime: this.initMaxTime,
type: this.initType,
categoryId: this.initCategoryId,
accountId: this.initAccountId
});
},
beforeRouteUpdate(to) {
if (to.query) {
this.init({
dateType: to.query.dateType,
minTime: to.query.minTime,
maxTime: to.query.maxTime,
type: to.query.type,
categoryId: to.query.categoryId,
accountId: to.query.accountId
});
}
},
methods: {
init(query) {
let dateRange = getDateRangeByDateType(query.dateType ? parseInt(query.dateType) : undefined, self.firstDayOfWeek);
if (!dateRange &&
query.dateType === datetimeConstants.allDateRanges.Custom.type.toString() &&
parseInt(query.maxTime) > 0 && parseInt(query.minTime) > 0) {
dateRange = {
dateType: parseInt(query.dateType),
maxTime: parseInt(query.maxTime),
minTime: parseInt(query.minTime)
};
}
this.transactionsStore.initTransactionListFilter({
dateType: dateRange ? dateRange.dateType : undefined,
maxTime: dateRange ? dateRange.maxTime : undefined,
minTime: dateRange ? dateRange.minTime : undefined,
type: parseInt(query.type) > 0 ? parseInt(query.type) : undefined,
categoryId: query.categoryId,
accountId: query.accountId,
keyword: this.searchKeyword
});
this.reload(false);
},
reload(force) {
const self = this;
self.loading = true;
Promise.all([
self.accountsStore.loadAllAccounts({ force: false }),
self.transactionCategoriesStore.loadAllCategories({ force: false })
]).then(() => {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
let yearMonth = null;
if (recentDateRange.year && recentDateRange.month) {
yearMonth = {
year: recentDateRange.year,
month: recentDateRange.month
};
}
return self.transactionsStore.loadTransactions({
reload: true,
yearMonth: yearMonth,
force: force,
autoExpand: true,
defaultCurrency: self.defaultCurrency
});
}).then(() => {
self.loading = false;
self.currentPage = 1;
if (force) {
self.$refs.snackbar.showMessage('Data has been updated');
}
}).catch(error => {
self.loading = false;
self.currentPage = 1;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
changeDateFilter(recentDateRange) {
if (recentDateRange.dateType === datetimeConstants.allDateRanges.Custom.type &&
!recentDateRange.minTime && !recentDateRange.maxTime) { // Custom
if (!this.query.minTime || !this.query.maxTime) {
this.query.maxTime = getActualUnixTimeForStore(getCurrentUnixTime(), this.currentTimezoneOffsetMinutes, getBrowserTimezoneOffsetMinutes());
this.query.minTime = getSpecifiedDayFirstUnixTime(this.query.maxTime);
}
this.showCustomDateRangeDialog = true;
return;
}
this.transactionsStore.updateTransactionListFilter({
dateType: recentDateRange.dateType,
maxTime: recentDateRange.maxTime,
minTime: recentDateRange.minTime
});
this.$router.push(this.getFilterLinkUrl());
},
changeCustomDateFilter(minTime, maxTime) {
if (!minTime || !maxTime) {
return;
}
this.transactionsStore.updateTransactionListFilter({
dateType: datetimeConstants.allDateRanges.Custom.type,
maxTime: maxTime,
minTime: minTime
});
this.showCustomDateRangeDialog = false;
this.$router.push(this.getFilterLinkUrl());
},
changeTypeFilter(type) {
if (this.query.type === type) {
return;
}
let removeCategoryFilter = false;
if (type && this.query.categoryId) {
const category = this.allCategories[this.query.categoryId];
if (category && category.type !== transactionTypeToCategoryType(type)) {
removeCategoryFilter = true;
}
}
this.transactionsStore.updateTransactionListFilter({
type: type,
categoryId: removeCategoryFilter ? '0' : undefined
});
this.$router.push(this.getFilterLinkUrl());
},
changeCategoryFilter(categoryId) {
if (this.query.categoryId === categoryId) {
return;
}
this.transactionsStore.updateTransactionListFilter({
categoryId: categoryId
});
this.showCategoryPopover = false;
this.$router.push(this.getFilterLinkUrl());
},
changeAccountFilter(accountId) {
if (this.query.accountId === accountId) {
return;
}
this.transactionsStore.updateTransactionListFilter({
accountId: accountId
});
this.$router.push(this.getFilterLinkUrl());
},
changeKeywordFilter(keyword) {
if (this.query.keyword === keyword) {
return;
}
this.transactionsStore.updateTransactionListFilter({
keyword: keyword
});
this.reload(false);
},
add() {
},
duplicate() {
},
show() {
},
edit() {
},
remove(transaction) {
const self = this;
self.$refs.confirmDialog.open('Are you sure you want to delete this transaction?').then(() => {
self.updating = true;
self.transactionRemoving[transaction.id] = true;
self.transactionsStore.deleteTransaction({
transaction: transaction,
defaultCurrency: self.defaultCurrency
}).then(() => {
self.updating = false;
self.transactionRemoving[transaction.id] = false;
}).catch(error => {
self.updating = false;
self.transactionRemoving[transaction.id] = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
shouldShowMonthlyData(transactionMonthList) {
const recentDateRange = this.recentMonthDateRanges[this.recentDateRangeType];
if (!recentDateRange || !recentDateRange.year || !recentDateRange.month) {
return true;
}
return transactionMonthList.year === recentDateRange.year && transactionMonthList.month === recentDateRange.month;
},
scrollCategoryMenuToSelectedItem(opened) {
if (opened) {
this.scrollMenuToSelectedItem(this.$refs.categoryFilterMenu);
}
},
scrollAccountMenuToSelectedItem(opened) {
if (opened) {
this.scrollMenuToSelectedItem(this.$refs.accountFilterMenu);
}
},
scrollMenuToSelectedItem(menu) {
this.$nextTick(() => {
scrollToMenuListItem(menu.contentEl);
});
},
getDisplayTime(transaction) {
return this.$locale.formatUnixTimeToShortTime(this.userStore, transaction.time, transaction.utcOffset, this.currentTimezoneOffsetMinutes);
},
getDisplayTimezone(transaction) {
return `UTC${getUtcOffsetByUtcOffsetMinutes(transaction.utcOffset)}`;
},
getDisplayAmount(amount, currency, hideAmount) {
if (hideAmount) {
return this.getDisplayCurrency('***', currency);
}
return this.getDisplayCurrency(amount, currency);
},
getDisplayMonthTotalAmount(amount, currency, symbol, incomplete) {
const displayAmount = this.getDisplayCurrency(amount, currency);
return symbol + displayAmount + (incomplete ? '+' : '');
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.getDisplayCurrency(value, currencyCode, {
currencyDisplayMode: this.settingsStore.appSettings.currencyDisplayMode,
enableThousandsSeparator: this.settingsStore.appSettings.thousandsSeparator
});
},
getLongDate(transaction) {
const transactionTime = getUnixTime(parseDateFromUnixTime(transaction.time, transaction.utcOffset, this.currentTimezoneOffsetMinutes));
return this.$locale.formatUnixTimeToLongDate(this.userStore, transactionTime);
},
getWeekdayLongName(transaction) {
return this.$locale.getWeekdayLongName(transaction.dayOfWeek);
},
getTransactionTypeName(type, defaultName) {
switch (type){
case this.allTransactionTypes.ModifyBalance:
return this.$t('Modify Balance');
case this.allTransactionTypes.Income:
return this.$t('Income');
case this.allTransactionTypes.Expense:
return this.$t('Expense');
case this.allTransactionTypes.Transfer:
return this.$t('Transfer');
default:
return this.$t(defaultName);
}
},
getTransactionTypeFromCategoryType(categoryType) {
return categoryTypeToTransactionType(parseInt(categoryType));
},
getCategoryListItemCheckedClass(category, queryCategoryId) {
if (category.id === queryCategoryId) {
return {
'list-item-selected': true,
'has-children-item-selected': true
};
}
for (let i = 0; i < category.subCategories.length; i++) {
if (category.subCategories[i].id === queryCategoryId) {
return {
'list-item-selected': true,
'has-children-item-selected': true
};
}
}
return [];
},
getFilterLinkUrl() {
return `/transactions?${this.transactionsStore.getTransactionListPageParams()}`;
}
}
}
};
</script>
<style>
.transaction-keyword-filter .v-input--density-compact {
--v-input-control-height: 36px;
--v-input-padding-top: 5px;
--v-input-padding-bottom: 5px;
inline-size: 20rem;
}
.transaction-type-buttons .v-btn:not(:first-child) {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.transaction-type-buttons .v-btn:not(:last-child) {
border-bottom: 0;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.transaction-list-title {
overflow-x: auto;
white-space: nowrap;
}
.transaction-list-datetime-range {
overflow-x: auto;
white-space: nowrap;
}
.transaction-list-datetime-range .transaction-list-datetime-range-text {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity)) !important;
}
.transaction-list-total-amount-text .v-skeleton-loader__text {
margin: 0;
}
.v-table.transaction-table .transaction-list-row-date > td {
height: 40px !important;
}
.transaction-table tr.transaction-table-row-data .hover-display {
display: none;
}
.transaction-table tr.transaction-table-row-data:hover .hover-display {
display: grid;
}
.transaction-table .transaction-table-column-time {
width: 110px;
white-space: nowrap;
}
.transaction-table .transaction-table-column-category {
width: 140px;
white-space: nowrap;
}
.transaction-table .transaction-table-column-amount {
width: 120px;
white-space: nowrap;
}
.transaction-table .transaction-table-column-account {
width: 160px;
white-space: nowrap;
}
.transaction-table .transaction-table-column-description {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.transaction-table .transaction-table-column-category .v-btn,
.transaction-table .transaction-table-column-account .v-btn {
font-size: 0.75rem;
}
.transaction-table .transaction-table-column-category .v-btn .v-btn__append,
.transaction-table .transaction-table-column-account .v-btn .v-btn__append {
margin-left: 0in;
}
.transaction-category-menu .item-icon,
.transaction-account-menu .item-icon,
.transaction-table .item-icon {
padding-bottom: 3px;
}
.transaction-category-menu .has-children-item-selected span {
font-weight: bold;
}
</style>