code refactor

This commit is contained in:
MaysWind
2025-09-13 21:17:55 +08:00
parent 1c4dc55bb6
commit 77d2426c14
3 changed files with 2454 additions and 2110 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,588 @@
<template>
<v-data-table
fixed-header
fixed-footer
density="compact"
item-value="index"
:class="{ 'import-transaction-table': true, 'disabled': !!disabled }"
:height="parsedFileLinesTableHeight"
:disable-sort="true"
:headers="parsedFileLinesHeaders"
:items="parsedFileLines"
:no-data-text="tt('No data to import')"
v-model:items-per-page="countPerPage"
v-model:page="currentPage"
>
<template #headers="{ columns }">
<tr>
<th class="text-no-wrap" :key="column.key ?? undefined" v-for="column in columns">
<span v-if="!column.key || column.key === 'index'">{{ column.title }}</span>
<div class="py-1" v-if="column.key && column.key !== 'index'">
<span>{{ getParseDataMappedColumnDisplayName(parseInt(column.key)) }}</span>
<br/>
<span>({{ column.title }})</span>
<v-menu activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="columnType.type"
:append-icon="parsedFileDataColumnMapping.dataColumnMapping[columnType.type] === parseInt(column.key) ? mdiCheck : undefined"
v-for="columnType in allImportTransactionColumnTypes"
@click="parsedFileDataColumnMapping.toggleDataMappingColumn(parseInt(column.key), columnType.type)">
<v-list-item-title class="cursor-pointer">
{{ columnType.displayName }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</th>
</tr>
</template>
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2" v-if="parsedFileData">
<v-btn color="secondary" density="compact" variant="outlined"
:append-icon="parsedFileDataColumnMapping.includeHeader ? mdiCheck : mdiClose"
@click="parsedFileDataColumnMapping.toggleIncludeHeader()">{{ tt('Include Header Line') }}</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) || !parsedFileAllTransactionTypes">
<span>{{ tt('Transaction Type Mapping') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) && parsedFileAllTransactionTypes">({{ getObjectOwnFieldCount(parsedFileValidMappedTransactionTypes) || tt('None') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
<v-list-item class="pa-0">
<v-table class="transaction-types-popup-menu">
<tbody>
<tr :key="typeName"
v-for="typeName in parsedFileAllTransactionTypes">
<td>{{ typeName }}</td>
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileDataColumnMapping.transactionTypeMapping[typeName]">
<v-btn :value="undefined">{{ tt('None') }}</v-btn>
<v-btn :value="TransactionType.ModifyBalance">{{ tt('Modify Balance') }}</v-btn>
<v-btn :value="TransactionType.Income">{{ tt('Income') }}</v-btn>
<v-btn :value="TransactionType.Expense">{{ tt('Expense') }}</v-btn>
<v-btn :value="TransactionType.Transfer">{{ tt('Transfer') }}</v-btn>
</v-btn-toggle>
</td>
</tr>
</tbody>
</v-table>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">
<span>{{ tt('Time Format') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">({{ parsedFileDataColumnMapping.timeFormat || parsedFileAutoDetectedTimeFormat || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.timeFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timeFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ms-1" v-if="parsedFileAutoDetectedTimeFormat">({{ parsedFileAutoDetectedTimeFormat }})</span>
<span class="ms-1" v-if="!parsedFileAutoDetectedTimeFormat">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="dateTimeFormat.format"
:append-icon="parsedFileDataColumnMapping.timeFormat === dateTimeFormat.format ? mdiCheck : undefined"
v-for="dateTimeFormat in KnownDateTimeFormat.values()"
@click="parsedFileDataColumnMapping.timeFormat = dateTimeFormat.format">
<v-list-item-title class="cursor-pointer">
{{ dateTimeFormat.format }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">
<span>{{ tt('Timezone Format') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">({{ KnownDateTimezoneFormat.valueOf(parsedFileDataColumnMapping.timezoneFormat || parsedFileAutoDetectedTimezoneFormat || '')?.name || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.timezoneFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timezoneFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ms-1" v-if="parsedFileAutoDetectedTimezoneFormat && KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')">({{ KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')?.name }})</span>
<span class="ms-1" v-if="!parsedFileAutoDetectedTimezoneFormat || !KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="timezoneFormat.value"
:append-icon="parsedFileDataColumnMapping.timezoneFormat === timezoneFormat.value ? mdiCheck : undefined"
v-for="timezoneFormat in KnownDateTimezoneFormat.values()"
@click="parsedFileDataColumnMapping.timezoneFormat = timezoneFormat.value">
<v-list-item-title class="cursor-pointer">
{{ timezoneFormat.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">
<span>{{ tt('Amount Format') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">({{ KnownAmountFormat.valueOf(parsedFileDataColumnMapping.amountFormat || parsedFileAutoDetectedAmountFormat || '')?.format || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileDataColumnMapping.amountFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.amountFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ms-1" v-if="parsedFileAutoDetectedAmountFormat && KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')?.format }})</span>
<span class="ms-1" v-if="!parsedFileAutoDetectedAmountFormat || !KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="amountFormat.type"
:append-icon="parsedFileDataColumnMapping.amountFormat === amountFormat.type ? mdiCheck : undefined"
v-for="amountFormat in KnownAmountFormat.values()"
@click="parsedFileDataColumnMapping.amountFormat = amountFormat.type">
<v-list-item-title class="cursor-pointer">
{{ amountFormat.format }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.GeographicLocation)">
<span>{{ tt('Geographic Location Separator') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping.geoLocationOrder">({{ parsedFileDataColumnMapping.formatGeoLocation(tt('Latitude'), tt('Longitude')) }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
<v-list-item class="pa-0">
<v-table class="transaction-types-popup-menu">
<tbody>
<tr :key="separator.value"
v-for="separator in allSeparators">
<td>{{ separator.name }} ({{separator.value}})</td>
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileDataColumnMapping.geoLocationOrder"
v-if="parsedFileDataColumnMapping.geoLocationSeparator === separator.value">
<v-btn value="latlon">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn value="lonlat">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-toggle>
<v-btn-group class="transaction-types-toggle" density="compact" variant="outlined"
divided v-if="parsedFileDataColumnMapping.geoLocationSeparator !== separator.value">
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'latlon')">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'lonlat')">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-group>
</td>
</tr>
</tbody>
</v-table>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ms-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Tags)">
<span>{{ tt('Transaction Tags Separator') }}</span>
<span class="ms-1" v-if="parsedFileDataColumnMapping.tagSeparator">({{ parsedFileDataColumnMapping.tagSeparator }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="separator.value"
:append-icon="parsedFileDataColumnMapping.tagSeparator === separator.value ? mdiCheck : undefined"
v-for="separator in allSeparators"
@click="parsedFileDataColumnMapping.tagSeparator = separator.value">
<v-list-item-title class="cursor-pointer">
{{ separator.name }} ({{separator.value}})
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-spacer/>
<span>{{ tt('Lines Per Page') }}</span>
<v-select class="ms-2" density="compact" max-width="100"
item-title="name"
item-value="value"
:disabled="!!disabled"
:items="parsedFileLinesTablePageOptions"
v-model="countPerPage"
/>
<pagination-buttons density="compact"
:disabled="!!disabled"
:totalPageCount="Math.ceil((parsedFileLines ? parsedFileLines.length : 0) / countPerPage)"
v-model="currentPage"></pagination-buttons>
</div>
</template>
</v-data-table>
<snack-bar ref="snackbar" />
</template>
<script setup lang="ts">
import SnackBar from '@/components/desktop/SnackBar.vue';
import PaginationButtons from '@/components/desktop/PaginationButtons.vue';
import { ref, computed, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { type NameValue, type NameNumeralValue, type TypeAndDisplayName, itemAndIndex, entries } from '@/core/base.ts';
import { type NumeralSystem, KnownAmountFormat } from '@/core/numeral.ts';
import { KnownDateTimeFormat } from '@/core/datetime.ts';
import { KnownDateTimezoneFormat } from '@/core/timezone.ts';
import { TransactionType } from '@/core/transaction.ts';
import { ImportTransactionColumnType, ImportTransactionDataMapping } from '@/core/import_transaction.ts';
import { KnownFileType } from '@/core/file.ts';
import {
isNumber,
isObjectEmpty,
getObjectOwnFieldCount,
findDisplayNameByType
} from '@/lib/common.ts';
import {
openTextFileContent,
startDownloadFile
} from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
import {
mdiCheck,
mdiClose,
mdiFolderOpenOutline,
mdiContentSaveOutline
} from '@mdi/js';
type SnackBarType = InstanceType<typeof SnackBar>;
interface ImportTransactionDefineColumnResult {
includeHeader: boolean;
columnMapping: Record<number, number>;
transactionTypeMapping: Record<string, TransactionType>;
timeFormat: string | undefined;
timezoneFormat: string | undefined;
amountDecimalSeparator: string | undefined;
amountDigitGroupingSymbol: string | undefined;
geoLocationSeparator: string;
geoLocationOrder: string;
tagSeparator: string;
}
interface ImportTransactionDefineColumnMenu {
prependIcon: string;
title: string;
disabled?: boolean;
onClick: () => void;
}
const props = defineProps<{
parsedFileData?: string[][];
disabled?: boolean;
}>();
const {
tt,
getCurrentNumeralSystemType,
getAllImportTransactionColumnTypes
} = useI18n();
const snackbar = useTemplateRef<SnackBarType>('snackbar');
const currentPage = ref<number>(1);
const countPerPage = ref<number>(10);
const parsedFileDataColumnMapping = ref<ImportTransactionDataMapping>(ImportTransactionDataMapping.createEmpty());
const numeralSystem = computed<NumeralSystem>(() => getCurrentNumeralSystemType());
const allImportTransactionColumnTypes = computed<TypeAndDisplayName[]>(() => getAllImportTransactionColumnTypes());
const menus = computed<ImportTransactionDefineColumnMenu[]>(() => [
{
prependIcon: mdiFolderOpenOutline,
title: tt('Load Data Mapping File'),
onClick: loadColumnMappingFile
},
{
prependIcon: mdiContentSaveOutline,
title: tt('Save Data Mapping File'),
onClick: saveColumnMappingFile
}
]);
const allSeparators = computed<NameValue[]>(() => {
const separators: NameValue[] = [
{
name: tt('Space'),
value: ' '
},
{
name: tt('Comma'),
value: ','
},
{
name: tt('Semicolon'),
value: ';'
},
{
name: tt('Tab'),
value: '\t'
},
{
name: tt('Vertical Bar'),
value: '|'
}
];
return separators;
});
const parsedFileLinesTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !parsedFileLines.value || parsedFileLines.value.length <= 10) {
return undefined;
} else {
return 400;
}
});
const parsedFileLinesHeaders = computed<object[]>(() => {
let maxColumnCount = 0;
if (props.parsedFileData) {
for (const lineData of props.parsedFileData) {
if (lineData.length > maxColumnCount) {
maxColumnCount = lineData.length;
}
}
}
const headers: object[] = [];
headers.push({ key: 'index', value: 'index', title: '#', sortable: true, nowrap: true });
for (let i = 0; i < maxColumnCount; i++) {
let title = `#${i + 1}`;
if (parsedFileDataColumnMapping.value.includeHeader && props.parsedFileData && props.parsedFileData[0][i]) {
title = props.parsedFileData[0][i] as string;
}
headers.push({ key: i.toString(), value: `column${i + 1}`, title: title, sortable: true, nowrap: true });
}
return headers;
});
const parsedFileLines = computed<Record<string, string>[] | undefined>(() => {
if (!props.parsedFileData) {
return undefined;
}
const allLines: Record<string, string>[] = [];
const startIndex = parsedFileDataColumnMapping.value.includeHeader ? 1 : 0;
for (let i = startIndex, index = 1; i < props.parsedFileData.length; i++, index++) {
const line: Record<string, string> = {};
const columns = props.parsedFileData[i] as string[];
for (const [data, columnIndex] of itemAndIndex(columns)) {
line['index'] = index.toString();
line[`column${columnIndex + 1}`] = data;
}
allLines.push(line);
}
return allLines;
});
const parsedFileLinesTablePageOptions = computed<NameNumeralValue[]>(() => getTablePageOptions(parsedFileLines.value?.length));
const parsedFileAllTransactionTypes = computed<string[]>(() => parsedFileDataColumnMapping.value.parseFileAllTransactionTypes(props.parsedFileData));
const parsedFileValidMappedTransactionTypes = computed<Record<string, TransactionType>>(() => parsedFileDataColumnMapping.value.parseFileValidMappedTransactionTypes(props.parsedFileData));
const parsedFileAutoDetectedTimeFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimeFormat(props.parsedFileData));
const parsedFileAutoDetectedTimezoneFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimezoneFormat(props.parsedFileData));
const parsedFileAutoDetectedAmountFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedAmountFormat(props.parsedFileData));
function getDisplayCount(count: number): string {
return numeralSystem.value.formatNumber(count);
}
function getTablePageOptions(linesCount?: number): NameNumeralValue[] {
const pageOptions: NameNumeralValue[] = [];
if (!linesCount || linesCount < 1) {
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
for (const count of [ 5, 10, 15, 20, 25, 30, 50 ]) {
if (linesCount < count) {
break;
}
pageOptions.push({ value: count, name: getDisplayCount(count) });
}
pageOptions.push({ value: -1, name: tt('All') });
return pageOptions;
}
function getParseDataMappedColumnDisplayName(columnIndex: number): string {
for (const [columnType, index] of entries(parsedFileDataColumnMapping.value.dataColumnMapping)) {
if (index === columnIndex) {
return findDisplayNameByType(allImportTransactionColumnTypes.value, parseInt(columnType)) || tt('Unspecified');
}
}
return tt('Unspecified');
}
function generateResult(): ImportTransactionDefineColumnResult | undefined {
const columnMapping: Record<number, number> = parsedFileDataColumnMapping.value.dataColumnMapping;
const transactionTypeMapping: Record<string, TransactionType> = parsedFileValidMappedTransactionTypes.value;
const includeHeader: boolean = parsedFileDataColumnMapping.value.includeHeader;
const geoLocationSeparator: string = parsedFileDataColumnMapping.value.geoLocationSeparator;
const geoLocationOrder: string = parsedFileDataColumnMapping.value.geoLocationOrder;
const tagSeparator: string = parsedFileDataColumnMapping.value.tagSeparator;
let timeFormat: string | undefined = parsedFileDataColumnMapping.value.timeFormat;
let timezoneFormat: string | undefined = parsedFileDataColumnMapping.value.timezoneFormat;
let amountFormat: string | undefined = parsedFileDataColumnMapping.value.amountFormat;
let amountDecimalSeparator: string | undefined = undefined;
let amountDigitGroupingSymbol: string | undefined = undefined;
if (!columnMapping
|| !isNumber(columnMapping[ImportTransactionColumnType.TransactionTime.type])
|| !isNumber(columnMapping[ImportTransactionColumnType.TransactionType.type])
|| !isNumber(columnMapping[ImportTransactionColumnType.Amount.type])) {
snackbar.value?.showError('Missing transaction time, transaction type, or amount column mapping');
return undefined;
}
if (!transactionTypeMapping || isObjectEmpty(transactionTypeMapping)) {
snackbar.value?.showError('Transaction type mapping is not set');
return undefined;
}
if (!parsedFileDataColumnMapping.value.timeFormat) {
timeFormat = parsedFileAutoDetectedTimeFormat.value;
}
if (!parsedFileDataColumnMapping.value.timezoneFormat) {
timezoneFormat = parsedFileAutoDetectedTimezoneFormat.value;
}
if (!parsedFileDataColumnMapping.value.amountFormat) {
amountFormat = parsedFileAutoDetectedAmountFormat.value;
}
if (amountFormat) {
const knownAmountFormat = KnownAmountFormat.valueOf(amountFormat);
if (knownAmountFormat) {
amountDecimalSeparator = knownAmountFormat.decimalSeparator.symbol;
amountDigitGroupingSymbol = knownAmountFormat.digitGroupingSymbol?.symbol;
}
}
if (!timeFormat) {
snackbar.value?.showError('Transaction time format is not set');
return undefined;
}
if (!amountDecimalSeparator) {
snackbar.value?.showError('Transaction amount format is not set');
return undefined;
}
return {
includeHeader: includeHeader,
columnMapping: columnMapping,
transactionTypeMapping: transactionTypeMapping,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoLocationSeparator: geoLocationSeparator,
geoLocationOrder: geoLocationOrder,
tagSeparator: tagSeparator
};
}
function reset(): void {
parsedFileDataColumnMapping.value.reset();
currentPage.value = 1;
countPerPage.value = 10;
}
function loadColumnMappingFile(): void {
openTextFileContent({
allowedExtensions: KnownFileType.JSON.contentType
}).then(content => {
const result = ImportTransactionDataMapping.parseFromJson(content);
if (result) {
parsedFileDataColumnMapping.value = result;
} else {
logger.error('Failed to parse data mapping file');
snackbar.value?.showError('Data mapping file is invalid');
}
}).catch(error => {
logger.error('Failed to open data mapping file', error);
snackbar.value?.showError('Data mapping file is invalid');
});
}
function saveColumnMappingFile(): void {
const fileName = KnownFileType.JSON.formatFileName(tt('dataExport.defaultImportDataMappingFileName'));
startDownloadFile(fileName, KnownFileType.JSON.createBlob(parsedFileDataColumnMapping.value.toJson()));
}
defineExpose({
menus,
generateResult,
reset,
loadColumnMappingFile,
saveColumnMappingFile
});
</script>
<style>
.transaction-types-popup-menu .transaction-types-toggle {
overflow-x: auto;
white-space: nowrap;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle {
height: auto !important;
padding: 0;
border: none;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle > .v-btn {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.transaction-types-popup-menu .transaction-types-toggle.v-btn-toggle button.v-btn {
width: auto !important;
}
</style>