Files
ezbookkeeping/src/views/desktop/accounts/list/dialogs/ReconciliationStatementDialog.vue
T
2025-09-14 17:18:47 +08:00

623 lines
29 KiB
Vue

<template>
<v-dialog :min-height="400" :persistent="loading" v-model="showState">
<v-card class="pa-6 pa-sm-10 pa-md-12">
<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('Reconciliation Statement') }}</h4>
<v-btn density="compact" color="default" variant="text" size="24"
class="ms-2" :icon="true" :loading="loading" @click="reload(true)">
<template #loader>
<v-progress-circular indeterminate size="20"/>
</template>
<v-icon :icon="mdiRefresh" size="24" />
<v-tooltip activator="parent">{{ tt('Refresh') }}</v-tooltip>
</v-btn>
</div>
<v-btn density="comfortable" color="default" variant="text" class="ms-2"
:icon="true" :disabled="loading"
v-if="showAccountBalanceTrendsCharts">
<v-icon :icon="mdiTuneVertical" />
<v-menu activator="parent">
<v-list>
<v-list-subheader :title="tt('Chart Type')"/>
<v-list-item :key="type.type"
:prepend-icon="chartTypeIconMap[type.type]"
:append-icon="chartType === type.type ? mdiCheck : undefined"
:title="type.displayName"
@click="chartType = type.type"
v-for="type in allChartTypes"></v-list-item>
<v-divider class="my-2"/>
<v-list-subheader :title="tt('Time Granularity')"/>
<v-list-item :prepend-icon="mdiCalendarTodayOutline"
:append-icon="chartDataDateAggregationType === undefined ? mdiCheck : undefined"
:title="tt('granularity.Daily')"
@click="chartDataDateAggregationType = undefined"></v-list-item>
<v-list-item :key="dateAggregationType.type"
:prepend-icon="chartDataDateAggregationTypeIconMap[dateAggregationType.type]"
:append-icon="chartDataDateAggregationType === dateAggregationType.type ? mdiCheck : undefined"
:title="dateAggregationType.displayName"
@click="chartDataDateAggregationType = dateAggregationType.type"
v-for="dateAggregationType in allDateAggregationTypes"></v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn density="comfortable" color="default" variant="text" class="ms-2"
:icon="true" :disabled="loading">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent">
<v-list>
<v-list-item :prepend-icon="mdiInvoiceTextPlusOutline"
:title="tt('Add Transaction')"
@click="addTransaction()"></v-list-item>
<v-list-item :prepend-icon="mdiInvoiceTextEditOutline"
:title="tt('Update Closing Balance')"
@click="updateClosingBalance()"></v-list-item>
<v-divider class="my-2"/>
<v-list-item :prepend-icon="mdiComma"
:disabled="!reconciliationStatements || !reconciliationStatements.transactions || reconciliationStatements.transactions.length < 1"
@click="exportReconciliationStatements(KnownFileType.CSV)">
<v-list-item-title>{{ tt('Export to CSV (Comma-separated values) File') }}</v-list-item-title>
</v-list-item>
<v-list-item :prepend-icon="mdiKeyboardTab"
:disabled="!reconciliationStatements || !reconciliationStatements.transactions || reconciliationStatements.transactions.length < 1"
@click="exportReconciliationStatements(KnownFileType.TSV)">
<v-list-item-title>{{ tt('Export to TSV (Tab-separated values) File') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
<template #subtitle>
<div class="text-body-1 text-center text-wrap mt-2" v-if="!startTime && !endTime">
<span>{{ tt('All') }}</span>
</div>
<div class="text-body-1 text-center text-wrap mt-2" v-if="startTime || endTime">
<span>{{ displayStartDateTime }}</span>
<span> - </span>
<span>{{ displayEndDateTime }}</span>
</div>
</template>
<v-card-text class="py-0 w-100 d-flex justify-center mt-n4">
<v-switch class="bidirectional-switch" color="secondary"
:label="tt('Account Balance Trends')"
v-model="showAccountBalanceTrendsCharts"
@click="showAccountBalanceTrendsCharts = !showAccountBalanceTrendsCharts">
<template #prepend>
<span>{{ tt('Transaction List') }}</span>
</template>
</v-switch>
</v-card-text>
<div class="d-flex align-center mb-4">
<div class="d-flex align-center text-body-1">
<span class="ms-2">{{ tt('Opening Balance') }}</span>
<span class="text-primary" v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-primary ms-2" v-else-if="!loading">
{{ displayOpeningBalance }}
</span>
<span class="ms-3">{{ tt('Closing Balance') }}</span>
<span class="text-primary" v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-primary ms-2" v-else-if="!loading">
{{ displayClosingBalance }}
</span>
</div>
<v-spacer/>
<div class="d-flex align-center text-body-1">
<span class="ms-2">{{ tt('Total Inflows') }}</span>
<span class="text-income" v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-income ms-2" v-else-if="!loading">
{{ displayTotalInflows }}
</span>
<span class="ms-3">{{ tt('Total Outflows') }}</span>
<span class="text-expense" v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-expense ms-2" v-else-if="!loading">
{{ displayTotalOutflows }}
</span>
<span class="ms-3">{{ tt('Net Cash Flow') }}</span>
<span class="text-primary" v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="text-primary ms-2" v-else-if="!loading">
{{ displayTotalBalance }}
</span>
</div>
</div>
<v-data-table
fixed-header
fixed-footer
multi-sort
density="compact"
item-value="index"
:class="{ 'disabled': loading }"
:headers="dataTableHeaders"
:items="reconciliationStatements?.transactions ?? []"
:no-data-text="loading ? '' : tt('No transaction data')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
v-if="!showAccountBalanceTrendsCharts"
>
<template #item.time="{ item }">
<span>{{ getDisplayDateTime(item) }}</span>
<v-chip class="ms-1" variant="flat" color="secondary" size="x-small"
v-if="item.utcOffset !== currentTimezoneOffsetMinutes">{{ getDisplayTimezone(item) }}</v-chip>
</template>
<template #item.type="{ item }">
<v-chip label variant="outlined" size="x-small"
:class="{ 'text-income' : item.type === TransactionType.Income, 'text-expense': item.type === TransactionType.Expense }"
:color="getTransactionTypeColor(item)">{{ getDisplayTransactionType(item) }}</v-chip>
</template>
<template #item.categoryId="{ item }">
<div class="d-flex align-center">
<ItemIcon size="24px" icon-type="category"
:icon-id="allCategoriesMap[item.categoryId]?.icon ?? ''"
:color="allCategoriesMap[item.categoryId]?.color ?? ''"
v-if="allCategoriesMap[item.categoryId] && allCategoriesMap[item.categoryId]?.color"></ItemIcon>
<v-icon size="24" :icon="mdiPencilBoxOutline" v-else-if="!allCategoriesMap[item.categoryId] || !allCategoriesMap[item.categoryId]?.color" />
<span class="ms-2" v-if="item.type === TransactionType.ModifyBalance">
{{ tt('Modify Balance') }}
</span>
<span class="ms-2" v-else-if="item.type !== TransactionType.ModifyBalance && allCategoriesMap[item.categoryId]">
{{ allCategoriesMap[item.categoryId]?.name }}
</span>
</div>
</template>
<template #item.sourceAmount="{ item }">
<span :class="{ 'text-expense': item.type === TransactionType.Expense, 'text-income': item.type === TransactionType.Income }">{{ getDisplaySourceAmount(item) }}</span>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer && item.sourceAccountId !== item.destinationAccountId && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.sourceAccountId !== item.destinationAccountId && getDisplaySourceAmount(item) !== getDisplayDestinationAmount(item)">{{ getDisplayDestinationAmount(item) }}</span>
</template>
<template #item.sourceAccountId="{ item }">
<div class="d-flex align-center">
<span v-if="item.sourceAccountId && allAccountsMap[item.sourceAccountId]">{{ allAccountsMap[item.sourceAccountId]?.name }}</span>
<v-icon class="icon-with-direction mx-1" size="13" :icon="mdiArrowRight" v-if="item.type === TransactionType.Transfer"></v-icon>
<span v-if="item.type === TransactionType.Transfer && item.destinationAccountId && allAccountsMap[item.destinationAccountId]">{{ allAccountsMap[item.destinationAccountId]?.name }}</span>
</div>
</template>
<template #item.accountBalance="{ item }">
<span>{{ getDisplayAccountBalance(item) }}</span>
</template>
<template #item.operation="{ item }">
<v-btn density="compact" variant="text" color="default" :disabled="loading || item.type === TransactionType.ModifyBalance"
@click="showTransaction(item)">
{{ tt('View') }}
</v-btn>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2" v-if="loading || (reconciliationStatements && reconciliationStatements.transactions && reconciliationStatements.transactions.length)">
<span class="ms-2">{{ tt('Total Transactions') }}</span>
<span v-if="loading">
<v-skeleton-loader class="skeleton-no-margin ms-3" type="text" style="width: 80px" :loading="true"></v-skeleton-loader>
</span>
<span class="ms-2" v-else-if="!loading">
{{ formatNumberToLocalizedNumerals(reconciliationStatements?.transactions.length ?? 0) }}
</span>
<v-spacer/>
<span v-if="reconciliationStatements && reconciliationStatements.transactions && reconciliationStatements.transactions.length > 10">
{{ tt('Transactions Per Page') }}
</span>
<v-select class="ms-2" density="compact" max-width="100"
item-title="name"
item-value="value"
:disabled="loading"
:items="reconciliationStatementsTablePageOptions"
v-model="countPerPage"
v-if="reconciliationStatements && reconciliationStatements.transactions && reconciliationStatements.transactions.length > 10"
/>
<pagination-buttons density="compact"
:disabled="loading"
:totalPageCount="totalPageCount"
v-model="currentPage"
v-if="reconciliationStatements && reconciliationStatements.transactions && reconciliationStatements.transactions.length > 10">
</pagination-buttons>
</div>
</template>
</v-data-table>
<account-balance-trends-chart
:type="chartType"
:date-aggregation-type="chartDataDateAggregationType"
:fiscal-year-start="fiscalYearStart"
:items="[]"
:legend-name="isCurrentLiabilityAccount ? tt('Account Outstanding Balance') : tt('Account Balance')"
:account="currentAccount"
:skeleton="true"
v-if="showAccountBalanceTrendsCharts && loading"
/>
<account-balance-trends-chart
:type="chartType"
:date-aggregation-type="chartDataDateAggregationType"
:fiscal-year-start="fiscalYearStart"
:items="reconciliationStatements?.transactions"
:legend-name="isCurrentLiabilityAccount ? tt('Account Outstanding Balance') : tt('Account Balance')"
:account="currentAccount"
v-if="showAccountBalanceTrendsCharts && !loading"
/>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center mt-2 mt-sm-4 mt-md-6 gap-4">
<v-btn color="secondary" variant="tonal"
:disabled="loading" @click="close">{{ tt('Close') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
<amount-input-dialog ref="amountInputDialog" />
<edit-dialog ref="editDialog" :type="TransactionEditPageType.Transaction" />
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import SnackBar from '@/components/desktop/SnackBar.vue';
import AmountInputDialog from '@/components/desktop/AmountInputDialog.vue';
import EditDialog from '@/views/desktop/transactions/list/dialogs/EditDialog.vue';
import { TransactionEditPageType } from '@/views/base/transactions/TransactionEditPageBase.ts';
import { ref, computed, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useReconciliationStatementPageBase } from '@/views/base/accounts/ReconciliationStatementPageBase.ts';
import { useAccountsStore } from '@/stores/account.ts';
import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
import type { NameNumeralValue } from '@/core/base.ts';
import type { NumeralSystem } from '@/core/numeral.ts';
import { TransactionType } from '@/core/transaction.ts';
import { AccountBalanceTrendChartType, ChartDateAggregationType } from '@/core/statistics.ts';
import { KnownFileType } from '@/core/file.ts';
import { Transaction, type TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts';
import { isEquals } from '@/lib/common.ts';
import { getCurrentUnixTime } from '@/lib/datetime.ts';
import { startDownloadFile } from '@/lib/ui/common.ts';
import {
mdiRefresh,
mdiArrowRight,
mdiTuneVertical,
mdiDotsVertical,
mdiCheck,
mdiChartBar,
mdiChartAreasplineVariant,
mdiChartWaterfall,
mdiCalendarTodayOutline,
mdiCalendarMonthOutline,
mdiLayersTripleOutline,
mdiInvoiceTextPlusOutline,
mdiInvoiceTextEditOutline,
mdiComma,
mdiKeyboardTab,
mdiPencilBoxOutline
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
type AmountInputDialogType = InstanceType<typeof AmountInputDialog>;
type EditDialogType = InstanceType<typeof EditDialog>;
const emit = defineEmits<{
(e: 'error', message: string): void;
}>();
const { tt, getCurrentNumeralSystemType, formatNumberToLocalizedNumerals } = useI18n();
const {
accountId,
startTime,
endTime,
reconciliationStatements,
currentTimezoneOffsetMinutes,
fiscalYearStart,
allChartTypes,
allDateAggregationTypes,
currentAccount,
currentAccountCurrency,
isCurrentLiabilityAccount,
allAccountsMap,
allCategoriesMap,
exportFileName,
displayStartDateTime,
displayEndDateTime,
displayTotalInflows,
displayTotalOutflows,
displayTotalBalance,
displayOpeningBalance,
displayClosingBalance,
getDisplayTransactionType,
getDisplayDateTime,
getDisplayTimezone,
getDisplaySourceAmount,
getDisplayDestinationAmount,
getDisplayAccountBalance,
getExportedData
} = useReconciliationStatementPageBase();
const accountsStore = useAccountsStore();
const transactionCategoriesStore = useTransactionCategoriesStore();
const transactionsStore = useTransactionsStore();
const chartTypeIconMap = {
[AccountBalanceTrendChartType.Column.type]: mdiChartBar,
[AccountBalanceTrendChartType.Area.type]: mdiChartAreasplineVariant,
[AccountBalanceTrendChartType.Candlestick.type]: mdiChartWaterfall,
};
const chartDataDateAggregationTypeIconMap = {
[ChartDateAggregationType.Month.type]: mdiCalendarMonthOutline,
[ChartDateAggregationType.Quarter.type]: mdiLayersTripleOutline,
[ChartDateAggregationType.Year.type]: mdiLayersTripleOutline,
[ChartDateAggregationType.FiscalYear.type]: mdiLayersTripleOutline,
};
const amountInputDialog = useTemplateRef<AmountInputDialogType>('amountInputDialog');
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const editDialog = useTemplateRef<EditDialogType>('editDialog');
const showState = ref<boolean>(false);
const loading = ref<boolean>(false);
const currentPage = ref<number>(1);
const countPerPage = ref<number>(10);
const showAccountBalanceTrendsCharts = ref<boolean>(false);
const chartType = ref<number>(AccountBalanceTrendChartType.Default.type);
const chartDataDateAggregationType = ref<number | undefined>(undefined);
let rejectFunc: ((reason?: unknown) => void) | null = null;
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const reconciliationStatementsTablePageOptions = computed<NameNumeralValue[]>(() => getTablePageOptions(reconciliationStatements.value?.transactions.length));
const totalPageCount = computed<number>(() => {
if (!reconciliationStatements.value || !reconciliationStatements.value.transactions || reconciliationStatements.value.transactions.length < 1) {
return 1;
}
const count = reconciliationStatements.value.transactions.length;
return Math.ceil(count / countPerPage.value);
});
const dataTableHeaders = computed<object[]>(() => {
const headers: object[] = [];
const accountBalanceName = isCurrentLiabilityAccount.value ? 'Account Outstanding Balance' : 'Account Balance';
headers.push({ key: 'time', value: 'time', title: tt('Transaction Time'), sortable: true, nowrap: true });
headers.push({ key: 'type', value: 'type', title: tt('Type'), sortable: true, nowrap: true });
headers.push({ key: 'categoryId', value: 'categoryId', title: tt('Category'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAmount', value: 'sourceAmount', title: tt('Amount'), sortable: true, nowrap: true });
headers.push({ key: 'sourceAccountId', value: 'sourceAccountId', title: tt('Account'), sortable: true, nowrap: true });
headers.push({ key: 'accountBalance', value: 'accountBalance', title: tt(accountBalanceName), sortable: true, nowrap: true });
headers.push({ key: 'comment', value: 'comment', title: tt('Description'), sortable: true, nowrap: true });
headers.push({ key: 'operation', title: tt('Operation'), sortable: false, nowrap: true, align: 'end' });
return headers;
});
function getTablePageOptions(linesCount?: number): NameNumeralValue[] {
const pageOptions: NameNumeralValue[] = [];
if (!linesCount || linesCount < 1) {
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
const availableCountPerPage = [ 5, 10, 15, 20, 25, 30, 50 ];
for (const count of availableCountPerPage) {
if (linesCount < count) {
break;
}
pageOptions.push({ value: count, name: numeralSystem.value.formatNumber(count) });
}
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
function getTransactionTypeColor(transaction: TransactionReconciliationStatementResponseItem): string | undefined {
if (transaction.type === TransactionType.ModifyBalance) {
return 'secondary';
} else if (transaction.type === TransactionType.Income) {
return undefined;
} else if (transaction.type === TransactionType.Expense) {
return undefined;
} else if (transaction.type === TransactionType.Transfer) {
return 'primary';
} else {
return 'default';
}
}
function open(options: { accountId: string, startTime: number, endTime: number }): Promise<void> {
accountId.value = options.accountId;
startTime.value = options.startTime;
endTime.value = options.endTime;
reconciliationStatements.value = undefined;
currentPage.value = 1;
countPerPage.value = 10;
showAccountBalanceTrendsCharts.value = false;
chartType.value = AccountBalanceTrendChartType.Default.type;
chartDataDateAggregationType.value = undefined;
showState.value = true;
loading.value = true;
Promise.all([
accountsStore.loadAllAccounts({ force: false }),
transactionCategoriesStore.loadAllCategories({ force: false })
]).then(() => {
return transactionsStore.getReconciliationStatements({
accountId: options.accountId,
startTime: options.startTime,
endTime: options.endTime
});
}).then(result => {
reconciliationStatements.value = result;
loading.value = false;
}).catch(error => {
loading.value = false;
if (!error.processed) {
emit('error', error);
showState.value = false;
}
});
return new Promise<void>((resolve, reject) => {
rejectFunc = reject;
});
}
function reload(force: boolean): void {
loading.value = true;
transactionsStore.getReconciliationStatements({
accountId: accountId.value,
startTime: startTime.value,
endTime: endTime.value
}).then(result => {
if (force) {
if (isEquals(reconciliationStatements.value, result)) {
snackbar.value?.showMessage('Data is up to date');
} else {
snackbar.value?.showMessage('Data has been updated');
}
}
reconciliationStatements.value = result;
loading.value = false;
}).catch(error => {
loading.value = false;
if (!error.processed) {
snackbar.value?.showError(error);
}
})
}
function addTransaction(): void {
editDialog.value?.open({
accountId: accountId.value
}).then(result => {
if (result && result.message) {
snackbar.value?.showMessage(result.message);
}
reload(false);
}).catch(error => {
if (error) {
snackbar.value?.showError(error);
}
});
}
function updateClosingBalance(): void {
let currentClosingBalance = reconciliationStatements.value?.closingBalance ?? 0;
if (isCurrentLiabilityAccount.value) {
currentClosingBalance = -currentClosingBalance;
}
amountInputDialog.value?.open({
text: 'Please enter the new closing balance for the account',
inputLabel: 'Closing Balance',
inputPlaceholder: 'Closing Balance',
currency: currentAccountCurrency.value,
initAmount: currentClosingBalance
}).then(newClosingBalance => {
if (!newClosingBalance) {
return;
}
const currentUnixTime = getCurrentUnixTime();
let newTransactionTime: number | undefined = undefined;
if (endTime.value > 0 && endTime.value < currentUnixTime) {
newTransactionTime = endTime.value;
} else if (currentUnixTime < startTime.value) {
newTransactionTime = startTime.value;
}
let newTransactionType: TransactionType = isCurrentLiabilityAccount.value ? TransactionType.Expense : TransactionType.Income;
let newTransactionAmount: number = newClosingBalance - currentClosingBalance;
if (newTransactionAmount < 0) {
newTransactionType = isCurrentLiabilityAccount.value ? TransactionType.Income : TransactionType.Expense;
newTransactionAmount = -newTransactionAmount;
}
editDialog.value?.open({
time: newTransactionTime,
type: newTransactionType,
amount: newTransactionAmount,
accountId: accountId.value,
noTransactionDraft: true
}).then(result => {
if (result && result.message) {
snackbar.value?.showMessage(result.message);
}
reload(false);
}).catch(error => {
if (error) {
snackbar.value?.showError(error);
}
});
});
}
function exportReconciliationStatements(fileType: KnownFileType): void {
if (!reconciliationStatements.value || !reconciliationStatements.value.transactions || reconciliationStatements.value.transactions.length < 1) {
return;
}
const exportedData = getExportedData(fileType);
startDownloadFile(fileType.formatFileName(exportFileName.value), fileType.createBlob(exportedData));
}
function showTransaction(transaction: TransactionReconciliationStatementResponseItem): void {
if (transaction.type === TransactionType.ModifyBalance) {
return;
}
editDialog.value?.open({
id: transaction.id,
currentTransaction: Transaction.of(transaction)
}).then(result => {
if (result && result.message) {
snackbar.value?.showMessage(result.message);
}
reload(false);
}).catch(error => {
if (error) {
snackbar.value?.showError(error);
}
});
}
function close(): void {
rejectFunc?.();
showState.value = false;
}
defineExpose({
open
});
</script>