Cleaned up account history, improved transaction tiles in dashboard and transactions list, and fixed small bug in balance record validation.
This commit is contained in:
parent
7d50b12a4f
commit
970ca46ef6
|
@ -2,25 +2,18 @@ package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
import com.andrewlalis.perfin.view.component.AccountHistoryView;
|
||||||
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
|
|
||||||
import javafx.application.Platform;
|
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.Node;
|
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class AccountViewController implements RouteSelectionListener {
|
public class AccountViewController implements RouteSelectionListener {
|
||||||
|
@ -35,10 +28,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
@FXML public Label accountBalanceLabel;
|
@FXML public Label accountBalanceLabel;
|
||||||
@FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false);
|
@FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false);
|
||||||
|
|
||||||
@FXML public VBox historyItemsVBox;
|
@FXML public AccountHistoryView accountHistory;
|
||||||
@FXML public Button loadMoreHistoryButton;
|
|
||||||
private LocalDateTime loadHistoryFrom;
|
|
||||||
private final int historyLoadSize = 5;
|
|
||||||
|
|
||||||
@FXML public VBox actionsVBox;
|
@FXML public VBox actionsVBox;
|
||||||
|
|
||||||
|
@ -66,15 +56,9 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
|
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
|
||||||
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
||||||
.thenAccept(accountBalanceLabel::setText);
|
.thenAccept(accountBalanceLabel::setText);
|
||||||
|
accountHistory.clear();
|
||||||
reloadHistory();
|
accountHistory.setAccountId(account.id);
|
||||||
}
|
accountHistory.loadMoreHistory();
|
||||||
|
|
||||||
public void reloadHistory() {
|
|
||||||
loadHistoryFrom = DateUtil.nowAsUTC();
|
|
||||||
historyItemsVBox.getChildren().clear();
|
|
||||||
loadMoreHistoryButton.setDisable(false);
|
|
||||||
loadMoreHistory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
|
@ -129,18 +113,4 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void loadMoreHistory() {
|
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
|
|
||||||
long historyId = repo.getOrCreateHistoryForAccount(account.id);
|
|
||||||
List<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
|
|
||||||
if (items.size() < historyLoadSize) {
|
|
||||||
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
|
|
||||||
} else {
|
|
||||||
loadHistoryFrom = items.getLast().getTimestamp();
|
|
||||||
}
|
|
||||||
List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
|
|
||||||
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
@ -56,16 +57,17 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
||||||
balanceWarningLabel.visibleProperty().set(false);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
if (!balanceValidator.validate(newValue).isValid()) {
|
if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get()) {
|
||||||
balanceWarningLabel.visibleProperty().set(false);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BigDecimal reportedBalance = new BigDecimal(newValue);
|
BigDecimal reportedBalance = new BigDecimal(newValue);
|
||||||
|
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||||
|
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
|
BigDecimal derivedBalance = repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC));
|
||||||
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
|
boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance);
|
||||||
!reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
|
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
|
||||||
));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -95,7 +97,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
||||||
));
|
));
|
||||||
if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
|
if (confirm && confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp))) {
|
||||||
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||||
repo.insert(
|
repo.insert(
|
||||||
DateUtil.localToUTC(localTimestamp),
|
DateUtil.localToUTC(localTimestamp),
|
||||||
|
@ -113,10 +115,10 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
router.navigateBackAndClear();
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
|
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
|
||||||
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
||||||
AccountRepository.class,
|
AccountRepository.class,
|
||||||
repo -> repo.deriveCurrentBalance(account.id)
|
repo -> repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC))
|
||||||
);
|
);
|
||||||
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
|
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
|
||||||
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
|
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
|
||||||
|
|
|
@ -6,6 +6,7 @@ import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AccountEntryRepository extends Repository, AutoCloseable {
|
public interface AccountEntryRepository extends Repository, AutoCloseable {
|
||||||
long insert(
|
long insert(
|
||||||
|
@ -16,6 +17,7 @@ public interface AccountEntryRepository extends Repository, AutoCloseable {
|
||||||
AccountEntry.Type type,
|
AccountEntry.Type type,
|
||||||
Currency currency
|
Currency currency
|
||||||
);
|
);
|
||||||
|
Optional<AccountEntry> findById(long id);
|
||||||
List<AccountEntry> findAllByAccountId(long accountId);
|
List<AccountEntry> findAllByAccountId(long accountId);
|
||||||
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
|
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,12 @@ import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
@ -32,4 +34,5 @@ public interface AccountRepository extends Repository, AutoCloseable {
|
||||||
return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
|
return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
|
||||||
}
|
}
|
||||||
Set<Currency> findAllUsedCurrencies();
|
Set<Currency> findAllUsedCurrencies();
|
||||||
|
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import java.util.Optional;
|
||||||
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
||||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||||
BalanceRecord findLatestByAccountId(long accountId);
|
BalanceRecord findLatestByAccountId(long accountId);
|
||||||
|
Optional<BalanceRecord> findById(long id);
|
||||||
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
||||||
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
||||||
List<Attachment> findAttachments(long recordId);
|
List<Attachment> findAttachments(long recordId);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface HistoryRepository extends Repository, AutoCloseable {
|
public interface HistoryRepository extends Repository, AutoCloseable {
|
||||||
long getOrCreateHistoryForAccount(long accountId);
|
long getOrCreateHistoryForAccount(long accountId);
|
||||||
|
@ -20,6 +21,7 @@ public interface HistoryRepository extends Repository, AutoCloseable {
|
||||||
default HistoryTextItem addTextItem(long historyId, String description) {
|
default HistoryTextItem addTextItem(long historyId, String description) {
|
||||||
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
|
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
|
||||||
}
|
}
|
||||||
|
Optional<HistoryItem> getItem(long id);
|
||||||
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
|
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
|
||||||
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
|
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
|
||||||
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
|
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
|
||||||
|
@ -12,11 +11,12 @@ import java.sql.SQLException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
|
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
|
||||||
long entryId = DbUtil.insertOne(
|
return DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"""
|
"""
|
||||||
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
|
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
|
||||||
|
@ -30,11 +30,16 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
|
||||||
currency.getCurrencyCode()
|
currency.getCurrencyCode()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
// Insert an entry into the account's history.
|
}
|
||||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
|
||||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
@Override
|
||||||
historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
|
public Optional<AccountEntry> findById(long id) {
|
||||||
return entryId;
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM account_entry WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcAccountEntryRepository::parse
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -5,10 +5,7 @@ import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.*;
|
||||||
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -169,6 +166,54 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults) {
|
||||||
|
var entryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
|
var historyRepo = new JdbcHistoryRepository(conn);
|
||||||
|
var balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||||
|
String query = """
|
||||||
|
SELECT id, type
|
||||||
|
FROM (
|
||||||
|
SELECT id, timestamp, 'ACCOUNT_ENTRY' AS type, account_id
|
||||||
|
FROM account_entry
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, timestamp, 'HISTORY_ITEM' AS type, account_id
|
||||||
|
FROM history_item
|
||||||
|
LEFT JOIN history_account ha ON history_item.history_id = ha.history_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
|
||||||
|
FROM balance_record
|
||||||
|
)
|
||||||
|
WHERE account_id = ? AND timestamp <= ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT\s""" + maxResults;
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
stmt.setLong(1, accountId);
|
||||||
|
stmt.setTimestamp(2, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||||
|
ResultSet rs = stmt.executeQuery();
|
||||||
|
List<Timestamped> entities = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
long id = rs.getLong(1);
|
||||||
|
String type = rs.getString(2);
|
||||||
|
Timestamped entity = switch (type) {
|
||||||
|
case "HISTORY_ITEM" -> historyRepo.getItem(id).orElse(null);
|
||||||
|
case "ACCOUNT_ENTRY" -> entryRepo.findById(id).orElse(null);
|
||||||
|
case "BALANCE_RECORD" -> balanceRecordRepo.findById(id).orElse(null);
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (entity == null) {
|
||||||
|
log.warn("Failed to find entity with id {} and type {}.", id, type);
|
||||||
|
} else {
|
||||||
|
entities.add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
log.error("Failed to find account events.", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(Account account) {
|
public void update(Account account) {
|
||||||
DbUtil.updateOne(
|
DbUtil.updateOne(
|
||||||
|
|
|
@ -37,10 +37,6 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add a history item entry.
|
|
||||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
|
||||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
|
||||||
historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
|
|
||||||
return recordId;
|
return recordId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -55,6 +51,16 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
).orElse(null);
|
).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<BalanceRecord> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM balance_record WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcBalanceRecordRepository::parse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
|
|
|
@ -113,6 +113,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
|
||||||
*/
|
*/
|
||||||
public void insertDefaultData(Connection conn) throws IOException, SQLException {
|
public void insertDefaultData(Connection conn) throws IOException, SQLException {
|
||||||
insertDefaultCategories(conn);
|
insertDefaultCategories(conn);
|
||||||
|
insertDefaultTags(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
|
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
|
||||||
|
@ -151,6 +152,18 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void insertDefaultTags(Connection conn) throws SQLException {
|
||||||
|
final List<String> defaultTags = List.of(
|
||||||
|
"!exclude"
|
||||||
|
);
|
||||||
|
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag (name) VALUES (?)")) {
|
||||||
|
for (var tag : defaultTags) {
|
||||||
|
stmt.setString(1, tag);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean testConnection(JdbcDataSource dataSource) {
|
private boolean testConnection(JdbcDataSource dataSource) {
|
||||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||||
return stmt.execute("SELECT 1;");
|
return stmt.execute("SELECT 1;");
|
||||||
|
|
|
@ -13,6 +13,7 @@ import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
|
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
|
||||||
@Override
|
@Override
|
||||||
|
@ -56,7 +57,7 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
|
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
|
||||||
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
|
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.Type.TEXT.name());
|
||||||
DbUtil.updateOne(
|
DbUtil.updateOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
|
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
|
||||||
|
@ -66,6 +67,16 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor
|
||||||
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
|
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<HistoryItem> getItem(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM history_item WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcHistoryRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
|
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
|
||||||
return DbUtil.insertOne(
|
return DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
|
@ -111,7 +122,7 @@ public record JdbcHistoryRepository(Connection conn) implements HistoryRepositor
|
||||||
long historyId = rs.getLong(2);
|
long historyId = rs.getLong(2);
|
||||||
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
|
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
|
||||||
String type = rs.getString(4);
|
String type = rs.getString(4);
|
||||||
if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
|
if (type.equalsIgnoreCase(HistoryItem.Type.TEXT.name())) {
|
||||||
String description = DbUtil.findOne(
|
String description = DbUtil.findOne(
|
||||||
rs.getStatement().getConnection(),
|
rs.getStatement().getConnection(),
|
||||||
"SELECT description FROM history_item_text WHERE id = ?",
|
"SELECT description FROM history_item_text WHERE id = ?",
|
||||||
|
|
|
@ -30,7 +30,7 @@ import java.util.Currency;
|
||||||
* all those extra accounts would be a burden to casual users.
|
* all those extra accounts would be a burden to casual users.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class AccountEntry extends IdEntity {
|
public class AccountEntry extends IdEntity implements Timestamped {
|
||||||
public enum Type {
|
public enum Type {
|
||||||
CREDIT,
|
CREDIT,
|
||||||
DEBIT
|
DEBIT
|
||||||
|
|
|
@ -9,7 +9,7 @@ import java.util.Currency;
|
||||||
* used as a sanity check for ensuring that an account's entries add up to the
|
* used as a sanity check for ensuring that an account's entries add up to the
|
||||||
* correct balance.
|
* correct balance.
|
||||||
*/
|
*/
|
||||||
public class BalanceRecord extends IdEntity {
|
public class BalanceRecord extends IdEntity implements Timestamped {
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final long accountId;
|
private final long accountId;
|
||||||
private final BigDecimal balance;
|
private final BigDecimal balance;
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public interface Timestamped {
|
||||||
|
/**
|
||||||
|
* Gets the timestamp at which the entity was created, in UTC timezone.
|
||||||
|
* @return The UTC timestamp at which this entity was created.
|
||||||
|
*/
|
||||||
|
LocalDateTime getTimestamp();
|
||||||
|
|
||||||
|
record Stub(long id, LocalDateTime timestamp) implements Timestamped {
|
||||||
|
@Override
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Stub fromResultSet(ResultSet rs) throws SQLException {
|
||||||
|
return new Stub(rs.getLong(1), DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ import java.util.Currency;
|
||||||
* actual positive/negative effect is determined by the associated account
|
* actual positive/negative effect is determined by the associated account
|
||||||
* entries that apply this transaction's amount to one or more accounts.
|
* entries that apply this transaction's amount to one or more accounts.
|
||||||
*/
|
*/
|
||||||
public class Transaction extends IdEntity {
|
public class Transaction extends IdEntity implements Timestamped {
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final BigDecimal amount;
|
private final BigDecimal amount;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.andrewlalis.perfin.model.history;
|
package com.andrewlalis.perfin.model.history;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.model.IdEntity;
|
import com.andrewlalis.perfin.model.IdEntity;
|
||||||
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@ -8,14 +9,16 @@ import java.time.LocalDateTime;
|
||||||
* Represents a single polymorphic history item. The item's "type" attribute
|
* Represents a single polymorphic history item. The item's "type" attribute
|
||||||
* tells where to find additional type-specific data.
|
* tells where to find additional type-specific data.
|
||||||
*/
|
*/
|
||||||
public abstract class HistoryItem extends IdEntity {
|
public abstract class HistoryItem extends IdEntity implements Timestamped {
|
||||||
public static final String TYPE_TEXT = "TEXT";
|
public enum Type {
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
private final long historyId;
|
private final long historyId;
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final String type;
|
private final Type type;
|
||||||
|
|
||||||
public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
|
public HistoryItem(long id, long historyId, LocalDateTime timestamp, Type type) {
|
||||||
super(id);
|
super(id);
|
||||||
this.historyId = historyId;
|
this.historyId = historyId;
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
|
@ -30,7 +33,7 @@ public abstract class HistoryItem extends IdEntity {
|
||||||
return timestamp;
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getType() {
|
public Type getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ public class HistoryTextItem extends HistoryItem {
|
||||||
private final String description;
|
private final String description;
|
||||||
|
|
||||||
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
|
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
|
||||||
super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
|
super(id, historyId, timestamp, HistoryItem.Type.TEXT);
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
|
||||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
|
||||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
|
||||||
import javafx.scene.control.Label;
|
|
||||||
import javafx.scene.layout.BorderPane;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A tile that shows a brief bit of information about an account history item.
|
|
||||||
*/
|
|
||||||
public abstract class AccountHistoryItemTile extends BorderPane {
|
|
||||||
public AccountHistoryItemTile(HistoryItem item) {
|
|
||||||
getStyleClass().add("tile");
|
|
||||||
|
|
||||||
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
|
|
||||||
timestampLabel.getStyleClass().add("small-font");
|
|
||||||
setTop(timestampLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AccountHistoryItemTile forItem(
|
|
||||||
HistoryItem item
|
|
||||||
) {
|
|
||||||
if (item instanceof HistoryTextItem t) {
|
|
||||||
return new AccountHistoryTextTile(t);
|
|
||||||
}
|
|
||||||
throw new RuntimeException("Unsupported history item type: " + item.getType());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
|
||||||
import javafx.scene.text.Text;
|
|
||||||
import javafx.scene.text.TextFlow;
|
|
||||||
|
|
||||||
public class AccountHistoryTextTile extends AccountHistoryItemTile {
|
|
||||||
public AccountHistoryTextTile(HistoryTextItem item) {
|
|
||||||
super(item);
|
|
||||||
setCenter(new TextFlow(new Text(item.getDescription())));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public class AccountHistoryTile extends VBox {
|
||||||
|
public AccountHistoryTile(LocalDateTime timestamp, Node centerContent) {
|
||||||
|
getStyleClass().add("history-tile");
|
||||||
|
|
||||||
|
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(timestamp));
|
||||||
|
timestampLabel.getStyleClass().addAll("small-font", "mono-font", "secondary-color-text-fill");
|
||||||
|
getChildren().add(timestampLabel);
|
||||||
|
getChildren().add(centerContent);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||||
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.*;
|
||||||
|
import javafx.geometry.Orientation;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.Hyperlink;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.Separator;
|
||||||
|
import javafx.scene.layout.AnchorPane;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
public class AccountHistoryView extends ScrollPane {
|
||||||
|
private LocalDateTime lastTimestamp = null;
|
||||||
|
private final BooleanProperty canLoadMore = new SimpleBooleanProperty(true);
|
||||||
|
private final VBox itemsVBox = new VBox();
|
||||||
|
private final LongProperty accountIdProperty = new SimpleLongProperty(-1L);
|
||||||
|
private final IntegerProperty initialItemsToLoadProperty = new SimpleIntegerProperty(10);
|
||||||
|
|
||||||
|
public AccountHistoryView() {
|
||||||
|
VBox scrollableContentVBox = new VBox();
|
||||||
|
scrollableContentVBox.getChildren().add(itemsVBox);
|
||||||
|
itemsVBox.setMinWidth(0);
|
||||||
|
|
||||||
|
Hyperlink loadMoreLink = new Hyperlink("Load more history");
|
||||||
|
loadMoreLink.setOnAction(event -> loadMoreHistory());
|
||||||
|
BindingUtil.bindManagedAndVisible(loadMoreLink, canLoadMore);
|
||||||
|
|
||||||
|
scrollableContentVBox.getChildren().add(new BorderPane(loadMoreLink));
|
||||||
|
itemsVBox.getStyleClass().addAll("tile-container");
|
||||||
|
this.setContent(scrollableContentVBox);
|
||||||
|
this.setFitToHeight(true);
|
||||||
|
this.setFitToWidth(true);
|
||||||
|
this.setHbarPolicy(ScrollBarPolicy.AS_NEEDED);
|
||||||
|
this.setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadMoreHistory() {
|
||||||
|
long accountId = accountIdProperty.get();
|
||||||
|
int maxItems = initialItemsToLoadProperty.get();
|
||||||
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
|
ds.mapRepoAsync(AccountRepository.class, repo -> repo.findEventsBefore(accountId, lastTimestamp(), maxItems))
|
||||||
|
.thenAccept(entities -> Platform.runLater(() -> addEntitiesToHistory(entities, maxItems)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
itemsVBox.getChildren().clear();
|
||||||
|
canLoadMore.set(true);
|
||||||
|
lastTimestamp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountId(long accountId) {
|
||||||
|
this.accountIdProperty.set(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property methods
|
||||||
|
public final IntegerProperty initialItemsToLoadProperty() {
|
||||||
|
return initialItemsToLoadProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final int getInitialItemsToLoad() {
|
||||||
|
return initialItemsToLoadProperty.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setInitialItemsToLoad(int value) {
|
||||||
|
initialItemsToLoadProperty.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime lastTimestamp() {
|
||||||
|
if (lastTimestamp == null) return DateUtil.nowAsUTC();
|
||||||
|
return lastTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node makeTile(Timestamped entity) {
|
||||||
|
switch (entity) {
|
||||||
|
case HistoryTextItem textItem -> {
|
||||||
|
return new AccountHistoryTile(textItem.getTimestamp(), new TextFlow(new Text(textItem.getDescription())));
|
||||||
|
}
|
||||||
|
case AccountEntry ae -> {
|
||||||
|
Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId());
|
||||||
|
txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId())));
|
||||||
|
String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT
|
||||||
|
? "credited %s from this account."
|
||||||
|
: "debited %s to this account.";
|
||||||
|
String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
||||||
|
TextFlow textFlow = new TextFlow(txLink, new Text(description));
|
||||||
|
return new AccountHistoryTile(ae.getTimestamp(), textFlow);
|
||||||
|
}
|
||||||
|
case BalanceRecord br -> {
|
||||||
|
Hyperlink brLink = new Hyperlink("Balance Record #" + br.id);
|
||||||
|
brLink.setOnAction(event -> router.navigate("balance-record", br));
|
||||||
|
return new AccountHistoryTile(br.getTimestamp(), new TextFlow(
|
||||||
|
brLink,
|
||||||
|
new Text("added with a value of %s.".formatted(CurrencyUtil.formatMoney(br.getMoneyAmount())))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
return new AccountHistoryTile(entity.getTimestamp(), new TextFlow(new Text("Unsupported entity: " + entity.getClass().getName())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addEntitiesToHistory(List<Timestamped> entities, int requestedItems) {
|
||||||
|
if (!itemsVBox.getChildren().isEmpty()) {
|
||||||
|
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
||||||
|
}
|
||||||
|
itemsVBox.getChildren().addAll(entities.stream()
|
||||||
|
.map(this::makeTile)
|
||||||
|
.map(tile -> {
|
||||||
|
// Use this to scrunch content to the left.
|
||||||
|
AnchorPane ap = new AnchorPane(tile);
|
||||||
|
AnchorPane.setLeftAnchor(tile, 0.0);
|
||||||
|
return ap;
|
||||||
|
})
|
||||||
|
.toList());
|
||||||
|
if (entities.size() < requestedItems) {
|
||||||
|
canLoadMore.set(false);
|
||||||
|
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
||||||
|
endMarker.getStyleClass().addAll("large-font", "italic-text");
|
||||||
|
itemsVBox.getChildren().add(endMarker);
|
||||||
|
}
|
||||||
|
if (!entities.isEmpty()) {
|
||||||
|
lastTimestamp = entities.getLast().getTimestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
|
public class CategoryLabel extends HBox {
|
||||||
|
public CategoryLabel(TransactionCategory category) {
|
||||||
|
Circle colorIndicator = new Circle(8, category.getColor());
|
||||||
|
Label label = new Label(category.getName());
|
||||||
|
this.getChildren().addAll(colorIndicator, label);
|
||||||
|
this.getStyleClass().add("std-spacing");
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
package com.andrewlalis.perfin.view.component;
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -16,11 +16,10 @@ import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.shape.Circle;
|
||||||
import javafx.scene.text.Text;
|
import javafx.scene.text.Text;
|
||||||
import javafx.scene.text.TextFlow;
|
import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,6 +34,7 @@ public class TransactionTile extends BorderPane {
|
||||||
setTop(getHeader(transaction));
|
setTop(getHeader(transaction));
|
||||||
setCenter(getBody(transaction));
|
setCenter(getBody(transaction));
|
||||||
setBottom(getFooter(transaction));
|
setBottom(getFooter(transaction));
|
||||||
|
setRight(getExtra(transaction));
|
||||||
|
|
||||||
selected.addListener((observable, oldValue, newValue) -> {
|
selected.addListener((observable, oldValue, newValue) -> {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
|
@ -71,7 +71,10 @@ public class TransactionTile extends BorderPane {
|
||||||
VBox bodyVBox = new VBox(
|
VBox bodyVBox = new VBox(
|
||||||
propertiesPane
|
propertiesPane
|
||||||
);
|
);
|
||||||
getCreditAndDebitAccounts(transaction).thenAccept(accounts -> {
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.findLinkedAccounts(transaction.id)
|
||||||
|
).thenAccept(accounts -> {
|
||||||
accounts.ifCredit(acc -> {
|
accounts.ifCredit(acc -> {
|
||||||
Hyperlink link = new Hyperlink(acc.getShortName());
|
Hyperlink link = new Hyperlink(acc.getShortName());
|
||||||
link.setOnAction(event -> router.navigate("account", acc));
|
link.setOnAction(event -> router.navigate("account", acc));
|
||||||
|
@ -99,10 +102,26 @@ public class TransactionTile extends BorderPane {
|
||||||
return footerHBox;
|
return footerHBox;
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
|
private Node getExtra(Transaction transaction) {
|
||||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
VBox content = new VBox();
|
||||||
TransactionRepository.class,
|
if (transaction.getCategoryId() != null) {
|
||||||
repo -> repo.findLinkedAccounts(transaction.id)
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
);
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(transaction.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> {
|
||||||
|
if (category == null) return;
|
||||||
|
Platform.runLater(() -> content.getChildren().add(new CategoryLabel(category)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (transaction.getVendorId() != null) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionVendorRepository.class,
|
||||||
|
repo -> repo.findById(transaction.getVendorId()).orElse(null)
|
||||||
|
).thenAccept(vendor -> {
|
||||||
|
if (vendor == null) return;
|
||||||
|
Platform.runLater(() -> content.getChildren().addLast(new Text("@ " + vendor.getName())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
package com.andrewlalis.perfin.view.component.module;
|
package com.andrewlalis.perfin.view.component.module;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.control.TransactionsViewController;
|
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import com.andrewlalis.perfin.view.component.CategoryLabel;
|
||||||
import com.andrewlalis.perfin.view.component.StyledText;
|
import com.andrewlalis.perfin.view.component.StyledText;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ScrollPane;
|
import javafx.scene.control.ScrollPane;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.*;
|
||||||
import javafx.scene.layout.Pane;
|
|
||||||
import javafx.scene.layout.Priority;
|
|
||||||
import javafx.scene.layout.VBox;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -45,6 +44,16 @@ public class RecentTransactionsModule extends DashboardModule {
|
||||||
refreshButton
|
refreshButton
|
||||||
));
|
));
|
||||||
this.getChildren().add(scrollPane);
|
this.getChildren().add(scrollPane);
|
||||||
|
|
||||||
|
Button viewVendorsButton = new Button("View Vendors");
|
||||||
|
viewVendorsButton.setOnAction(event -> router.navigate("vendors"));
|
||||||
|
Button viewCategoriesButton = new Button("View Categories");
|
||||||
|
viewCategoriesButton.setOnAction(event -> router.navigate("categories"));
|
||||||
|
Button viewTagsButton = new Button("View Tags");
|
||||||
|
viewTagsButton.setOnAction(event -> router.navigate("tags"));
|
||||||
|
HBox footerButtonBox = new HBox(viewVendorsButton, viewCategoriesButton, viewTagsButton);
|
||||||
|
footerButtonBox.getStyleClass().addAll("std-padding", "std-spacing", "small-font");
|
||||||
|
this.getChildren().add(footerButtonBox);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -87,12 +96,22 @@ public class RecentTransactionsModule extends DashboardModule {
|
||||||
Label descriptionLabel = new Label(tx.getDescription());
|
Label descriptionLabel = new Label(tx.getDescription());
|
||||||
BindingUtil.bindManagedAndVisible(descriptionLabel, descriptionLabel.textProperty().isNotEmpty());
|
BindingUtil.bindManagedAndVisible(descriptionLabel, descriptionLabel.textProperty().isNotEmpty());
|
||||||
|
|
||||||
|
|
||||||
Label balanceLabel = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(tx.getMoneyAmount()));
|
Label balanceLabel = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(tx.getMoneyAmount()));
|
||||||
balanceLabel.getStyleClass().addAll("mono-font");
|
balanceLabel.getStyleClass().addAll("mono-font");
|
||||||
|
VBox rightPanel = new VBox(balanceLabel);
|
||||||
|
if (tx.getCategoryId() != null) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(tx.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> {
|
||||||
|
if (category != null) Platform.runLater(() -> rightPanel.getChildren().add(new CategoryLabel(category)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
VBox contentBox = new VBox(dateLabel, descriptionLabel, linkedAccountsLabel);
|
VBox contentBox = new VBox(dateLabel, descriptionLabel, linkedAccountsLabel);
|
||||||
borderPane.setCenter(contentBox);
|
borderPane.setCenter(contentBox);
|
||||||
borderPane.setRight(balanceLabel);
|
borderPane.setRight(rightPanel);
|
||||||
|
|
||||||
return borderPane;
|
return borderPane;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import com.andrewlalis.perfin.view.component.AccountHistoryView?>
|
||||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||||
<?import javafx.scene.control.*?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
|
@ -49,11 +50,11 @@
|
||||||
<VBox styleClass="std-padding,std-spacing">
|
<VBox styleClass="std-padding,std-spacing">
|
||||||
<Label text="Actions" styleClass="bold-text"/>
|
<Label text="Actions" styleClass="bold-text"/>
|
||||||
<VBox fx:id="actionsVBox" styleClass="std-spacing">
|
<VBox fx:id="actionsVBox" styleClass="std-spacing">
|
||||||
<Button text="Edit" onAction="#goToEditPage"/>
|
<Button text="Edit" onAction="#goToEditPage" maxWidth="Infinity"/>
|
||||||
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
|
<Button text="Record Balance" onAction="#goToCreateBalanceRecord" maxWidth="Infinity"/>
|
||||||
<Button text="Archive" onAction="#archiveAccount"/>
|
<Button text="Archive" onAction="#archiveAccount" maxWidth="Infinity"/>
|
||||||
<Button text="Delete" onAction="#deleteAccount"/>
|
<Button text="Delete" onAction="#deleteAccount" maxWidth="Infinity"/>
|
||||||
<Button text="Unarchive" onAction="#unarchiveAccount"/>
|
<Button text="Unarchive" onAction="#unarchiveAccount" maxWidth="Infinity"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
</right>
|
</right>
|
||||||
|
@ -62,20 +63,7 @@
|
||||||
<!-- Account history -->
|
<!-- Account history -->
|
||||||
<VBox VBox.vgrow="ALWAYS">
|
<VBox VBox.vgrow="ALWAYS">
|
||||||
<Label text="History" styleClass="bold-text,std-padding"/>
|
<Label text="History" styleClass="bold-text,std-padding"/>
|
||||||
<VBox>
|
<AccountHistoryView fx:id="accountHistory" initialItemsToLoad="10"/>
|
||||||
<ScrollPane styleClass="tile-container-scroll">
|
|
||||||
<VBox fx:id="historyItemsVBox" styleClass="tile-container"/>
|
|
||||||
</ScrollPane>
|
|
||||||
<AnchorPane>
|
|
||||||
<Button
|
|
||||||
fx:id="loadMoreHistoryButton"
|
|
||||||
text="Load more history"
|
|
||||||
onAction="#loadMoreHistory"
|
|
||||||
AnchorPane.leftAnchor="0.0"
|
|
||||||
AnchorPane.rightAnchor="0.0"
|
|
||||||
/>
|
|
||||||
</AnchorPane>
|
|
||||||
</VBox>
|
|
||||||
</VBox>
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
</center>
|
</center>
|
||||||
|
|
|
@ -110,6 +110,12 @@ Text {
|
||||||
-fx-background-color: -fx-theme-background-3;
|
-fx-background-color: -fx-theme-background-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-tile {
|
||||||
|
-fx-background-color: -fx-theme-background-2;
|
||||||
|
-fx-padding: 10px;
|
||||||
|
-fx-background-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Validation styling. */
|
/* Validation styling. */
|
||||||
.validation-field-invalid {
|
.validation-field-invalid {
|
||||||
-fx-border-color: -fx-theme-negative;
|
-fx-border-color: -fx-theme-negative;
|
||||||
|
|
Loading…
Reference in New Issue