Added dashboard page and initial account and transaction modules.

This commit is contained in:
Andrew Lalis 2024-02-04 12:35:59 -05:00
parent 6900fdb481
commit f9a0fea9ab
16 changed files with 378 additions and 14 deletions

View File

@ -84,6 +84,7 @@ public class PerfinApp extends Application {
msgConsumer.accept("Initializing application views."); msgConsumer.accept("Initializing application views.");
Platform.runLater(() -> { Platform.runLater(() -> {
// App pages. // App pages.
router.map("dashboard", PerfinApp.class.getResource("/dashboard.fxml"));
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml")); router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
router.map("account", PerfinApp.class.getResource("/account-view.fxml")); router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml")); router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));

View File

@ -0,0 +1,39 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.view.component.module.AccountsModule;
import com.andrewlalis.perfin.view.component.module.DashboardModule;
import com.andrewlalis.perfin.view.component.module.RecentTransactionsModule;
import javafx.fxml.FXML;
import javafx.geometry.Bounds;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.FlowPane;
public class DashboardController implements RouteSelectionListener {
@FXML public ScrollPane modulesScrollPane;
@FXML public FlowPane modulesFlowPane;
private DashboardModule accountsModule;
private DashboardModule transactionsModule;
@FXML public void initialize() {
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
modulesFlowPane.minWidthProperty().bind(viewportWidth);
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
accountsModule = new AccountsModule(modulesFlowPane);
accountsModule.columnsProperty.set(2);
transactionsModule = new RecentTransactionsModule(modulesFlowPane);
transactionsModule.columnsProperty.set(2);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule);
}
@Override
public void onRouteSelected(Object context) {
accountsModule.refreshContents();
transactionsModule.refreshContents();
}
}

View File

@ -40,7 +40,7 @@ public class MainViewController {
} }
); );
router.navigate("accounts"); router.navigate("dashboard");
// Initialize the help manual components. // Initialize the help manual components.
helpPane.managedProperty().bind(helpPane.visibleProperty()); helpPane.managedProperty().bind(helpPane.visibleProperty());
@ -75,14 +75,6 @@ public class MainViewController {
router.navigateForward(); router.navigateForward();
} }
@FXML public void goToAccounts() {
router.replace("accounts");
}
@FXML public void goToTransactions() {
router.replace("transactions");
}
@FXML public void viewProfiles() { @FXML public void viewProfiles() {
ProfilesStage.open(mainContainer.getScene().getWindow()); ProfilesStage.open(mainContainer.getScene().getWindow());
} }
@ -106,4 +98,8 @@ public class MainViewController {
@FXML public void helpViewTransactions() { @FXML public void helpViewTransactions() {
helpRouter.replace("transactions"); helpRouter.replace("transactions");
} }
@FXML public void goToDashboard() {
router.replace("dashboard");
}
} }

View File

@ -108,7 +108,7 @@ public class ProfilesViewController {
Profile.setCurrent(PerfinApp.profileLoader.load(name)); Profile.setCurrent(PerfinApp.profileLoader.load(name));
ProfileLoader.saveLastProfile(name); ProfileLoader.saveLastProfile(name);
ProfilesStage.closeView(); ProfilesStage.closeView();
router.replace("accounts"); router.replace("dashboard");
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
return true; return true;
} catch (ProfileLoadException e) { } catch (ProfileLoadException e) {

View File

@ -17,6 +17,8 @@ public interface AccountRepository extends Repository, AutoCloseable {
long insert(AccountType type, String accountNumber, String name, Currency currency); long insert(AccountType type, String accountNumber, String name, Currency currency);
Page<Account> findAll(PageRequest pagination); Page<Account> findAll(PageRequest pagination);
List<Account> findAllOrderedByRecentHistory(); List<Account> findAllOrderedByRecentHistory();
List<Account> findTopNOrderedByRecentHistory(int n);
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
List<Account> findAllByCurrency(Currency currency); List<Account> findAllByCurrency(Currency currency);
Optional<Account> findById(long id); Optional<Account> findById(long id);
void updateName(long id, String name); void updateName(long id, String name);

View File

@ -28,6 +28,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
); );
Optional<Transaction> findById(long id); Optional<Transaction> findById(long id);
Page<Transaction> findAll(PageRequest pagination); Page<Transaction> findAll(PageRequest pagination);
List<Transaction> findRecentN(int n);
long countAll(); long countAll();
long countAllAfter(long transactionId); long countAllAfter(long transactionId);
long countAllByAccounts(Set<Long> accountIds); long countAllByAccounts(Set<Long> accountIds);

View File

@ -3,6 +3,7 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.*; import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.pagination.Page; 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.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
@ -66,6 +67,40 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
); );
} }
@Override
public List<Account> findTopNOrderedByRecentHistory(int n) {
return DbUtil.findAll(
conn,
"""
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived
ORDER BY hi.timestamp DESC, account.created_at DESC
LIMIT\s""" + n,
JdbcAccountRepository::parseAccount
);
}
@Override
public List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive) {
LocalDateTime cutoff = DateUtil.nowAsUTC().minusDays(daysSinceLastActive);
return DbUtil.findAll(
conn,
"""
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived AND hi.timestamp >= ?
ORDER BY hi.timestamp DESC, account.created_at DESC
LIMIT\s""" + n,
List.of(DbUtil.timestampFromUtcLDT(cutoff)),
JdbcAccountRepository::parseAccount
);
}
@Override @Override
public List<Account> findAllByCurrency(Currency currency) { public List<Account> findAllByCurrency(Currency currency) {
return DbUtil.findAll( return DbUtil.findAll(

View File

@ -142,6 +142,15 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
); );
} }
@Override
public List<Transaction> findRecentN(int n) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT " + n,
JdbcTransactionRepository::parseTransaction
);
}
@Override @Override
public long countAll() { public long countAll() {
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L); return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);

View File

@ -25,7 +25,7 @@ import static com.andrewlalis.perfin.PerfinApp.router;
* A compact tile that displays information about an account. * A compact tile that displays information about an account.
*/ */
public class AccountTile extends BorderPane { public class AccountTile extends BorderPane {
private static final Map<AccountType, String> ACCOUNT_TYPE_COLORS = Map.of( public static final Map<AccountType, String> ACCOUNT_TYPE_COLORS = Map.of(
AccountType.CHECKING, "-fx-theme-account-type-checking", AccountType.CHECKING, "-fx-theme-account-type-checking",
AccountType.SAVINGS, "-fx-theme-account-type-savings", AccountType.SAVINGS, "-fx-theme-account-type-savings",
AccountType.CREDIT_CARD, "-fx-theme-account-type-credit-card" AccountType.CREDIT_CARD, "-fx-theme-account-type-credit-card"

View File

@ -16,6 +16,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.helpRouter; import static com.andrewlalis.perfin.PerfinApp.helpRouter;
import static com.andrewlalis.perfin.PerfinApp.router;
/** /**
* A component that renders markdown-ish text as a series of TextFlow elements, * A component that renders markdown-ish text as a series of TextFlow elements,
@ -26,9 +27,15 @@ public class StyledText extends VBox {
private StringProperty text; private StringProperty text;
private boolean initialized = false; private boolean initialized = false;
public StyledText() {
getStyleClass().add("spacing-extra");
}
public final void setText(String value) { public final void setText(String value) {
initialized = false;
if (value == null) value = ""; if (value == null) value = "";
textProperty().set(value); textProperty().set(value);
layoutChildren(); // Re-render the underlying text.
} }
public final String getText() { public final String getText() {
@ -54,7 +61,6 @@ public class StyledText extends VBox {
String s = getText(); String s = getText();
getChildren().clear(); getChildren().clear();
getChildren().addAll(renderText(s)); getChildren().addAll(renderText(s));
getStyleClass().add("spacing-extra");
initialized = true; initialized = true;
} }
super.layoutChildren(); super.layoutChildren();
@ -135,6 +141,8 @@ public class StyledText extends VBox {
hyperlink.setOnAction(event -> PerfinApp.instance.getHostServices().showDocument(link)); hyperlink.setOnAction(event -> PerfinApp.instance.getHostServices().showDocument(link));
} else if (link.startsWith("help:")) { } else if (link.startsWith("help:")) {
hyperlink.setOnAction(event -> helpRouter.navigate(link.substring(5).strip())); hyperlink.setOnAction(event -> helpRouter.navigate(link.substring(5).strip()));
} else if (link.startsWith("app:")) {
hyperlink.setOnAction(event -> router.navigate(link.substring(4).strip()));
} }
} }
hyperlink.setBorder(Border.EMPTY); hyperlink.setBorder(Border.EMPTY);

View File

@ -0,0 +1,101 @@
package com.andrewlalis.perfin.view.component.module;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.AccountTile;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* A module that displays a basic overview of recent accounts.
*/
public class AccountsModule extends DashboardModule {
private final VBox accountsVBox = new VBox();
public AccountsModule(Pane parent) {
super(parent);
accountsVBox.getStyleClass().add("tile-container");
ScrollPane scrollPane = new ScrollPane(accountsVBox);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
Button addAccountButton = new Button("Add Account");
addAccountButton.setOnAction(event -> router.navigate("edit-account", null));
Button viewAllAccountsButton = new Button("All Accounts");
viewAllAccountsButton.setOnAction(event -> router.navigate("accounts"));
Button refreshButton = new Button("Refresh");
refreshButton.setOnAction(event -> refreshContents());
this.getChildren().add(new ModuleHeader(
"Recently Active Accounts",
addAccountButton,
viewAllAccountsButton,
refreshButton
));
this.getChildren().add(scrollPane);
}
@Override
public void refreshContents() {
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
AccountRepository::findAllOrderedByRecentHistory
)
.thenApply(accounts -> accounts.stream().map(AccountsModule::buildMiniAccountTile).toList())
.thenAccept(nodes -> Platform.runLater(() -> {
accountsVBox.getChildren().clear();
accountsVBox.getChildren().addAll(nodes);
}));
}
private static Node buildMiniAccountTile(Account account) {
BorderPane borderPane = new BorderPane();
borderPane.getStyleClass().addAll("tile", "hand-cursor");
borderPane.setOnMouseClicked(event -> router.navigate("account", account));
Label nameLabel = new Label(account.getName());
nameLabel.getStyleClass().addAll("bold-text");
Label numberLabel = new Label(account.getAccountNumber());
numberLabel.getStyleClass().addAll("mono-font");
Label typeLabel = new Label(account.getType().toString());
typeLabel.getStyleClass().add("bold-text");
typeLabel.setStyle("-fx-text-fill: " + AccountTile.ACCOUNT_TYPE_COLORS.get(account.getType()));
Label balanceLabel = new Label("Computing balance...");
balanceLabel.getStyleClass().addAll("mono-font");
balanceLabel.setDisable(true);
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.deriveCurrentBalance(account.id)
).thenAccept(bal -> Platform.runLater(() -> {
String text = CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(bal, account.getCurrency()));
balanceLabel.setText(text);
if (account.getType().areDebitsPositive() && bal.compareTo(BigDecimal.ZERO) < 0) {
balanceLabel.getStyleClass().add("negative-color-text-fill");
} else if (!account.getType().areDebitsPositive() && bal.compareTo(BigDecimal.ZERO) < 0) {
balanceLabel.getStyleClass().add("positive-color-text-fill");
}
balanceLabel.setDisable(false);
}));
VBox contentBox = new VBox(nameLabel, numberLabel, typeLabel);
borderPane.setCenter(contentBox);
borderPane.setRight(balanceLabel);
return borderPane;
}
}

View File

@ -0,0 +1,36 @@
package com.andrewlalis.perfin.view.component.module;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.value.ObservableValue;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
/**
* A container intended to display content on the app's dashboard, where modules
* are arranged in a flexible grid based on some constraints.
*/
public class DashboardModule extends VBox {
public final IntegerProperty columnsProperty = new SimpleIntegerProperty(1);
public final DoubleProperty minColumnWidthProperty = new SimpleDoubleProperty(500);
public final DoubleProperty rowHeightProperty = new SimpleDoubleProperty(500);
public DashboardModule(Pane parent) {
ObservableValue<Double> dynamicWidth = parent.widthProperty().map(parentWidth -> {
double parentWidthD = parentWidth.doubleValue();
if (parentWidthD < minColumnWidthProperty.doubleValue() * columnsProperty.doubleValue()) return parentWidthD;
return Math.floor(parentWidthD / columnsProperty.doubleValue());
});
this.minWidthProperty().bind(dynamicWidth);
this.prefWidthProperty().bind(dynamicWidth);
this.maxWidthProperty().bind(dynamicWidth);
this.minHeightProperty().bind(rowHeightProperty);
this.prefHeightProperty().bind(rowHeightProperty);
this.maxHeightProperty().bind(rowHeightProperty);
}
public void refreshContents() {}
}

View File

@ -0,0 +1,25 @@
package com.andrewlalis.perfin.view.component.module;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
/**
* A standardized header format for dashboard modules, which includes a left-aligned
* title and right-aligned list of action items (usually buttons).
*/
public class ModuleHeader extends BorderPane {
public ModuleHeader(String title, Node... actionItems) {
this.getStyleClass().addAll("std-padding");
Label titleLabel = new Label(title);
titleLabel.getStyleClass().addAll("bold-text", "large-font");
this.setLeft(titleLabel);
HBox actionsHBox = new HBox();
actionsHBox.getStyleClass().addAll("std-spacing", "small-font");
actionsHBox.getChildren().addAll(actionItems);
this.setRight(actionsHBox);
}
}

View File

@ -0,0 +1,99 @@
package com.andrewlalis.perfin.view.component.module;
import com.andrewlalis.perfin.control.TransactionsViewController;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.StyledText;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import static com.andrewlalis.perfin.PerfinApp.router;
public class RecentTransactionsModule extends DashboardModule {
private final VBox transactionsVBox = new VBox();
public RecentTransactionsModule(Pane parent) {
super(parent);
transactionsVBox.getStyleClass().add("tile-container");
ScrollPane scrollPane = new ScrollPane(transactionsVBox);
scrollPane.setFitToHeight(true);
scrollPane.setFitToWidth(true);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
Button addTransactionButton = new Button("Add Transaction");
addTransactionButton.setOnAction(event -> router.navigate("edit-transaction"));
Button viewTransactionsButton = new Button("All Transactions");
viewTransactionsButton.setOnAction(event -> router.navigate("transactions"));
Button refreshButton = new Button("Refresh");
refreshButton.setOnAction(event -> refreshContents());
this.getChildren().add(new ModuleHeader(
"Recent Transactions",
addTransactionButton,
viewTransactionsButton,
refreshButton
));
this.getChildren().add(scrollPane);
}
@Override
public void refreshContents() {
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
repo -> repo.findRecentN(5)
)
.thenApply(transactions -> transactions.stream().map(RecentTransactionsModule::buildMiniTransactionTile).toList())
.thenAccept(nodes -> Platform.runLater(() -> {
transactionsVBox.getChildren().clear();
transactionsVBox.getChildren().addAll(nodes);
}));
}
private static Node buildMiniTransactionTile(Transaction tx) {
BorderPane borderPane = new BorderPane();
borderPane.getStyleClass().addAll("tile", "hand-cursor");
borderPane.setOnMouseClicked(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(tx.id)));
Label dateLabel = new Label(DateUtil.formatUTCAsLocalWithZone(tx.getTimestamp()));
dateLabel.getStyleClass().addAll("mono-font", "small-font", "secondary-color-text-fill");
StyledText linkedAccountsLabel = new StyledText();
linkedAccountsLabel.getStyleClass().addAll("small-font");
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
repo -> repo.findLinkedAccounts(tx.id)
)
.thenAccept(accounts -> Platform.runLater(() -> {
StringBuilder sb = new StringBuilder();
if (accounts.hasCredit()) {
sb.append("Credited from **").append(accounts.creditAccount().getName()).append("**");
if (accounts.hasDebit()) sb.append(". ");
}
if (accounts.hasDebit()) {
sb.append("Debited to **").append(accounts.debitAccount().getName()).append("**");
}
linkedAccountsLabel.setText(sb.toString());
}));
Label descriptionLabel = new Label(tx.getDescription());
BindingUtil.bindManagedAndVisible(descriptionLabel, descriptionLabel.textProperty().isNotEmpty());
Label balanceLabel = new Label(CurrencyUtil.formatMoneyWithCurrencyPrefix(tx.getMoneyAmount()));
balanceLabel.getStyleClass().addAll("mono-font");
VBox contentBox = new VBox(dateLabel, descriptionLabel, linkedAccountsLabel);
borderPane.setCenter(contentBox);
borderPane.setRight(balanceLabel);
return borderPane;
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.FlowPane?>
<VBox xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.DashboardController"
>
<ScrollPane fitToHeight="true" fitToWidth="true" VBox.vgrow="ALWAYS" fx:id="modulesScrollPane" hbarPolicy="NEVER">
<FlowPane fx:id="modulesFlowPane"/>
</ScrollPane>
</VBox>

View File

@ -15,8 +15,7 @@
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="std-padding,std-spacing">
<Button text="Back" onAction="#goBack"/> <Button text="Back" onAction="#goBack"/>
<Button text="Forward" onAction="#goForward"/> <Button text="Forward" onAction="#goForward"/>
<Button text="Accounts" onAction="#goToAccounts"/> <Button text="Dashboard" onAction="#goToDashboard"/>
<Button text="Transactions" onAction="#goToTransactions"/>
<Button text="Profiles" onAction="#viewProfiles"/> <Button text="Profiles" onAction="#viewProfiles"/>
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/> <Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>