Refactor creating a balance record.
This commit is contained in:
parent
c02e5d3fc6
commit
5f692bf8e2
|
@ -8,28 +8,56 @@ import com.andrewlalis.perfin.model.Account;
|
|||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||
@FXML public TextField timestampField;
|
||||
@FXML public TextField balanceField;
|
||||
@FXML public VBox attachmentsVBox;
|
||||
private FileSelectionArea attachmentSelectionArea;
|
||||
@FXML public PropertiesPane propertiesPane;
|
||||
|
||||
@FXML public Button saveButton;
|
||||
|
||||
private Account account;
|
||||
|
||||
@FXML public void initialize() {
|
||||
attachmentSelectionArea = new FileSelectionArea(FileUtil::newAttachmentsFileChooser, () -> attachmentsVBox.getScene().getWindow());
|
||||
var timestampValid = new ValidationApplier<String>(input -> {
|
||||
try {
|
||||
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
|
||||
return ValidationResult.valid();
|
||||
} catch (DateTimeParseException e) {
|
||||
return ValidationResult.of("Invalid timestamp format.");
|
||||
}
|
||||
}).validatedInitially().attachToTextField(timestampField);
|
||||
|
||||
var balanceValid = new ValidationApplier<>(
|
||||
new CurrencyAmountValidator(() -> account == null ? null : account.getCurrency(), true, false)
|
||||
).validatedInitially().attachToTextField(balanceField);
|
||||
|
||||
var formValid = timestampValid.and(balanceValid);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
|
||||
// Manually append the attachment selection area to the end of the properties pane.
|
||||
attachmentSelectionArea = new FileSelectionArea(
|
||||
FileUtil::newAttachmentsFileChooser,
|
||||
() -> timestampField.getScene().getWindow()
|
||||
);
|
||||
attachmentSelectionArea.allowMultiple.set(true);
|
||||
attachmentsVBox.getChildren().add(attachmentSelectionArea);
|
||||
propertiesPane.getChildren().addLast(attachmentSelectionArea);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -48,10 +76,19 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
}
|
||||
|
||||
@FXML public void save() {
|
||||
// TODO: Add validation.
|
||||
Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> {
|
||||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
||||
boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
|
||||
account.getShortName(),
|
||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
||||
));
|
||||
if (confirm) {
|
||||
Profile.getCurrent().getDataSource().useAccountRepository(accountRepo -> {
|
||||
BigDecimal currentDerivedBalance = accountRepo.deriveCurrentBalance(account.id);
|
||||
|
||||
});
|
||||
Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> {
|
||||
repo.insert(
|
||||
DateUtil.localToUTC(localTimestamp),
|
||||
account.id,
|
||||
|
@ -62,6 +99,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
});
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
router.navigateBackAndClear();
|
||||
|
|
|
@ -46,9 +46,6 @@ public class CreateTransactionController implements RouteSelectionListener {
|
|||
|
||||
@FXML public Button saveButton;
|
||||
|
||||
public CreateTransactionController() {
|
||||
}
|
||||
|
||||
@FXML public void initialize() {
|
||||
// Setup error field validation.
|
||||
var timestampValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
@ -17,4 +18,5 @@ public interface AccountEntryRepository extends AutoCloseable {
|
|||
Currency currency
|
||||
);
|
||||
List<AccountEntry> findAllByAccountId(long accountId);
|
||||
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
|
||||
}
|
||||
|
|
|
@ -7,9 +7,12 @@ import java.nio.file.Path;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BalanceRecordRepository extends AutoCloseable {
|
||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||
BalanceRecord findLatestByAccountId(long accountId);
|
||||
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
||||
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
||||
void deleteById(long id);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl;
|
|||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
|
@ -46,6 +47,20 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
|
|||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
|
||||
List.of(
|
||||
accountId,
|
||||
DbUtil.timestampFromUtcLDT(utcMin),
|
||||
DbUtil.timestampFromUtcLDT(utcMax)
|
||||
),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.EntityNotFoundException;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
|
@ -10,16 +12,22 @@ import com.andrewlalis.perfin.model.Account;
|
|||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||
public record JdbcAccountRepository(Connection conn, Path contentDir) implements AccountRepository {
|
||||
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
|
||||
|
||||
@Override
|
||||
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
|
||||
return DbUtil.doTransaction(conn, () -> {
|
||||
|
@ -84,49 +92,37 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
|||
// First find the account itself, since its properties influence the balance.
|
||||
Account account = findById(accountId).orElse(null);
|
||||
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
|
||||
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
|
||||
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
||||
// Find the most recent balance record before timestamp.
|
||||
Optional<BalanceRecord> closestPastRecord = DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
|
||||
if (closestPastRecord.isPresent()) {
|
||||
// Then find any entries on the account since that balance record and the timestamp.
|
||||
List<AccountEntry> entriesAfterRecord = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
|
||||
List.of(
|
||||
accountId,
|
||||
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
|
||||
DbUtil.timestampFromInstant(timestamp)
|
||||
),
|
||||
JdbcAccountEntryRepository::parse
|
||||
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
||||
account.id,
|
||||
closestPastRecord.get().getTimestamp(),
|
||||
utcTimestamp
|
||||
);
|
||||
return computeBalanceWithEntriesAfter(account, closestPastRecord.get(), entriesAfterRecord);
|
||||
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
||||
} else {
|
||||
// There is no balance record present before the given timestamp. Try and find the closest one after.
|
||||
Optional<BalanceRecord> closestFutureRecord = DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
if (closestFutureRecord.isEmpty()) {
|
||||
throw new IllegalStateException("No balance record exists for account " + accountId);
|
||||
}
|
||||
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
|
||||
if (closestFutureRecord.isPresent()) {
|
||||
// Now find any entries on the account from the timestamp until that balance record.
|
||||
List<AccountEntry> entriesBeforeRecord = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC",
|
||||
List.of(
|
||||
accountId,
|
||||
DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()),
|
||||
DbUtil.timestampFromInstant(timestamp)
|
||||
),
|
||||
JdbcAccountEntryRepository::parse
|
||||
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
||||
account.id,
|
||||
utcTimestamp,
|
||||
closestFutureRecord.get().getTimestamp()
|
||||
);
|
||||
return computeBalanceWithEntriesBefore(account, closestFutureRecord.get(), entriesBeforeRecord);
|
||||
return computeBalanceWithEntries(account.getType(), closestFutureRecord.get(), entriesBetweenNowAndFutureRecord);
|
||||
} else {
|
||||
// No balance records exist for the account! Assume balance of 0 when the account was created.
|
||||
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
|
||||
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BigDecimal.ZERO, account.getCurrency());
|
||||
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
|
||||
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -191,18 +187,19 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
|||
conn.close();
|
||||
}
|
||||
|
||||
private BigDecimal computeBalanceWithEntriesAfter(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesAfterRecord) {
|
||||
BigDecimal balance = balanceRecord.getBalance();
|
||||
for (AccountEntry entry : entriesAfterRecord) {
|
||||
balance = balance.add(entry.getEffectiveValue(account.getType()));
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
private BigDecimal computeBalanceWithEntriesBefore(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesBeforeRecord) {
|
||||
private BigDecimal computeBalanceWithEntries(AccountType accountType, BalanceRecord balanceRecord, List<AccountEntry> entries) {
|
||||
List<AccountEntry> entriesBeforeRecord = entries.stream()
|
||||
.filter(entry -> entry.getTimestamp().isBefore(balanceRecord.getTimestamp()))
|
||||
.toList();
|
||||
List<AccountEntry> entriesAfterRecord = entries.stream()
|
||||
.filter(entry -> entry.getTimestamp().isAfter(balanceRecord.getTimestamp()))
|
||||
.toList();
|
||||
BigDecimal balance = balanceRecord.getBalance();
|
||||
for (AccountEntry entry : entriesBeforeRecord) {
|
||||
balance = balance.subtract(entry.getEffectiveValue(account.getType()));
|
||||
balance = balance.subtract(entry.getEffectiveValue(accountType));
|
||||
}
|
||||
for (AccountEntry entry : entriesAfterRecord) {
|
||||
balance = balance.add(entry.getEffectiveValue(accountType));
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import java.sql.SQLException;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||
@Override
|
||||
|
@ -51,6 +52,26 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
|||
).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(long id) {
|
||||
DbUtil.updateOne(conn, "DELETE FROM balance_record WHERE id = ?", List.of(id));
|
||||
|
|
|
@ -1,35 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import javafx.scene.text.TextFlow?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.CreateBalanceRecordController"
|
||||
stylesheets="@style/base.css"
|
||||
>
|
||||
<top>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Label text="Create New Balance Record" styleClass="large-text,bold-text"/>
|
||||
</HBox>
|
||||
<Label text="Create New Balance Record" styleClass="large-text,bold-text,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox styleClass="std-padding,std-spacing">
|
||||
<VBox>
|
||||
<Label text="Timestamp" labelFor="${timestampField}"/>
|
||||
<TextField fx:id="timestampField"/>
|
||||
<VBox styleClass="std-padding,std-spacing" style="-fx-max-width: 500px;" BorderPane.alignment="TOP_LEFT">
|
||||
<VBox styleClass="padding-extra,spacing-extra,small-font">
|
||||
<TextFlow>
|
||||
<Text styleClass="secondary-color-fill">
|
||||
Create a new recording of the current balance of your
|
||||
account. This will serve as a sort of key-frame; a true,
|
||||
known balance that Perfin will use to derive your account's
|
||||
current balance by applying recent transactions to it.
|
||||
</Text>
|
||||
</TextFlow>
|
||||
<TextFlow>
|
||||
<Text styleClass="secondary-color-fill">
|
||||
Therefore, it's important to make balance records
|
||||
periodically so that Perfin's calculations are accurate, and
|
||||
it serves as a nice sanity-check to make sure all your
|
||||
transactions add up.
|
||||
</Text>
|
||||
</TextFlow>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<Label text="Balance" labelFor="${balanceField}"/>
|
||||
<TextField fx:id="balanceField"/>
|
||||
</VBox>
|
||||
<VBox fx:id="attachmentsVBox">
|
||||
<Label text="Attachments"/>
|
||||
</VBox>
|
||||
</VBox>
|
||||
</center>
|
||||
<bottom>
|
||||
|
||||
<PropertiesPane vgap="5" hgap="5" fx:id="propertiesPane">
|
||||
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Balance" labelFor="${balanceField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="balanceField" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Button text="Save" onAction="#save"/>
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
>
|
||||
<center>
|
||||
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||
<VBox style="-fx-max-width: 400px;">
|
||||
<VBox style="-fx-max-width: 500px;">
|
||||
<!-- Basic properties -->
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
</HBox>
|
||||
</top>
|
||||
<center>
|
||||
<VBox style="-fx-max-width: 400px;" BorderPane.alignment="TOP_LEFT">
|
||||
<VBox style="-fx-max-width: 500px;" BorderPane.alignment="TOP_LEFT">
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
|
|
|
@ -25,11 +25,16 @@ rather than with your own CSS.
|
|||
-fx-font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.largest-font {
|
||||
-fx-font-size: 24px;
|
||||
}
|
||||
.large-font {
|
||||
-fx-font-size: 18px;
|
||||
}
|
||||
|
||||
.small-font {
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
.smallest-font {
|
||||
-fx-font-size: 10px;
|
||||
}
|
||||
|
||||
|
@ -101,6 +106,9 @@ rather than with your own CSS.
|
|||
-fx-text-fill: -fx-theme-text;
|
||||
}
|
||||
|
||||
.secondary-color-text-fill {
|
||||
-fx-text-fill: -fx-theme-text-secondary;
|
||||
}
|
||||
.secondary-color-fill {
|
||||
-fx-fill: -fx-theme-text-secondary;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue