Added transactions page, lots of utilities, and fixed account balance derivation formula.
This commit is contained in:
parent
b6e1481805
commit
14e1248b54
|
@ -26,6 +26,7 @@ public class PerfinApp extends Application {
|
|||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
// TODO: Cleanup the splash screen logic!
|
||||
SplashScreenStage splashStage = new SplashScreenStage("Loading", SceneUtil.load("/startup-splash-screen.fxml"));
|
||||
splashStage.show();
|
||||
defineRoutes();
|
||||
|
@ -43,9 +44,15 @@ public class PerfinApp extends Application {
|
|||
stage.setTitle("Perfin");
|
||||
}
|
||||
|
||||
private static void mapResourceRoute(String route, String resource) {
|
||||
router.map(route, PerfinApp.class.getResource(resource));
|
||||
}
|
||||
|
||||
private static void defineRoutes() {
|
||||
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
|
||||
router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
|
||||
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));
|
||||
mapResourceRoute("accounts", "/accounts-view.fxml");
|
||||
mapResourceRoute("account", "/account-view.fxml");
|
||||
mapResourceRoute("edit-account", "/edit-account.fxml");
|
||||
mapResourceRoute("transactions", "/transactions-view.fxml");
|
||||
mapResourceRoute("create-transaction", "/create-transaction.fxml");
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class AccountTileController {
|
||||
private Account account;
|
||||
|
||||
@FXML
|
||||
public VBox container;
|
||||
@FXML
|
||||
public Label accountNumberLabel;
|
||||
@FXML
|
||||
public Label accountBalanceLabel;
|
||||
@FXML
|
||||
public VBox accountNameBox;
|
||||
@FXML
|
||||
public Label accountNameLabel;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
ObservableValue<Boolean> accountNameTextPresent = accountNameLabel.textProperty().map(t -> t != null && !t.isBlank());
|
||||
accountNameBox.visibleProperty().bind(accountNameTextPresent);
|
||||
accountNameBox.managedProperty().bind(accountNameTextPresent);
|
||||
}
|
||||
|
||||
public void setAccount(Account account) {
|
||||
this.account = account;
|
||||
Platform.runLater(() -> {
|
||||
accountNumberLabel.setText(account.getAccountNumber());
|
||||
accountBalanceLabel.setText(account.getCurrency().getSymbol());
|
||||
accountNameLabel.setText(account.getName());
|
||||
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> router.navigate("account", account));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.fxml.FXML;
|
||||
|
@ -24,6 +25,8 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
public TextField accountCreatedAtField;
|
||||
@FXML
|
||||
public TextField accountCurrencyField;
|
||||
@FXML
|
||||
public TextField accountBalanceField;
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
|
@ -33,7 +36,8 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
accountNameField.setText(account.getName());
|
||||
accountNumberField.setText(account.getAccountNumber());
|
||||
accountCurrencyField.setText(account.getCurrency().getDisplayName());
|
||||
accountCreatedAtField.setText(account.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
accountCreatedAtField.setText(account.getCreatedAt().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
Profile.getCurrent().getDataSource().getAccountBalanceText(account, accountBalanceField::setText);
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
|
|
@ -1,52 +1,41 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.SceneUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.control.component.AccountTile;
|
||||
import com.andrewlalis.perfin.data.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.FlowPane;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Currency;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class AccountsViewController implements RouteSelectionListener {
|
||||
@FXML
|
||||
public BorderPane mainContainer;
|
||||
@FXML
|
||||
public FlowPane accountsPane;
|
||||
@FXML
|
||||
public Label noAccountsLabel;
|
||||
@FXML
|
||||
public Label totalLabel;
|
||||
|
||||
private final ObservableList<Account> accountsList = FXCollections.observableArrayList();
|
||||
private final BooleanProperty noAccounts = new SimpleBooleanProperty(false);
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
// Sync the size of the accounts pane to its container.
|
||||
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
|
||||
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
|
||||
|
||||
// Map each account in our list to an account tile element.
|
||||
BindingUtil.mapContent(accountsPane.getChildren(), accountsList, account -> SceneUtil.loadNode(
|
||||
"/account-tile.fxml",
|
||||
(Consumer<AccountTileController>) c -> c.setAccount(account)
|
||||
));
|
||||
|
||||
// Show the "no accounts" label when the accountsList is empty.
|
||||
var listProp = new SimpleListProperty<>(accountsList);
|
||||
noAccountsLabel.visibleProperty().bind(listProp.emptyProperty());
|
||||
noAccountsLabel.managedProperty().bind(noAccountsLabel.visibleProperty());
|
||||
accountsPane.visibleProperty().bind(listProp.emptyProperty().not());
|
||||
accountsPane.managedProperty().bind(accountsPane.visibleProperty());
|
||||
noAccountsLabel.visibleProperty().bind(noAccounts);
|
||||
noAccountsLabel.managedProperty().bind(noAccounts);
|
||||
accountsPane.visibleProperty().bind(noAccounts.not());
|
||||
accountsPane.managedProperty().bind(noAccounts.not());
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
@ -61,7 +50,26 @@ public class AccountsViewController implements RouteSelectionListener {
|
|||
|
||||
public void refreshAccounts() {
|
||||
Profile.whenLoaded(profile -> {
|
||||
accountsList.setAll(profile.getDataSource().getAccountRepository().findAll());
|
||||
Thread.ofVirtual().start(() -> {
|
||||
profile.getDataSource().useAccountRepository(repo -> {
|
||||
var page = repo.findAll(PageRequest.unpaged(Sort.asc("created_at")));
|
||||
Platform.runLater(() -> {
|
||||
accountsPane.getChildren().setAll(page.items().stream().map(AccountTile::new).toList());
|
||||
});
|
||||
});
|
||||
});
|
||||
// Compute grand totals!
|
||||
Thread.ofVirtual().start(() -> {
|
||||
var totals = profile.getDataSource().getCombinedAccountBalances();
|
||||
StringBuilder sb = new StringBuilder("Totals: ");
|
||||
for (var entry : totals.entrySet()) {
|
||||
Currency cur = entry.getKey();
|
||||
BigDecimal value = entry.getValue().setScale(cur.getDefaultFractionDigits(), RoundingMode.HALF_UP);
|
||||
sb.append(cur.getCurrencyCode()).append(' ').append(CurrencyUtil.formatMoney(value, cur)).append(' ');
|
||||
}
|
||||
Platform.runLater(() -> totalLabel.setText(sb.toString().strip()));
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class CreateTransactionController implements RouteSelectionListener {
|
||||
@FXML public TextField timestampField;
|
||||
@FXML public Label timestampInvalidLabel;
|
||||
@FXML public Label timestampFutureLabel;
|
||||
|
||||
@FXML public TextField amountField;
|
||||
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
||||
@FXML public TextArea descriptionField;
|
||||
|
||||
@FXML public ComboBox<Account> linkDebitAccountComboBox;
|
||||
@FXML public ComboBox<Account> linkCreditAccountComboBox;
|
||||
@FXML public Label linkedAccountsErrorLabel;
|
||||
|
||||
@FXML public void initialize() {
|
||||
// Setup error field validation.
|
||||
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
|
||||
timestampFutureLabel.managedProperty().bind(timestampFutureLabel.visibleProperty());
|
||||
timestampField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
LocalDateTime parsedTimestamp = parseTimestamp();
|
||||
timestampInvalidLabel.setVisible(parsedTimestamp == null);
|
||||
timestampFutureLabel.setVisible(parsedTimestamp != null && parsedTimestamp.isAfter(LocalDateTime.now()));
|
||||
});
|
||||
linkedAccountsErrorLabel.managedProperty().bind(linkedAccountsErrorLabel.visibleProperty());
|
||||
linkedAccountsErrorLabel.visibleProperty().bind(linkedAccountsErrorLabel.textProperty().isNotEmpty());
|
||||
linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
|
||||
linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
|
||||
|
||||
|
||||
// Update the lists of accounts available for linking based on the selected currency.
|
||||
var cellFactory = new AccountComboBoxCellFactory();
|
||||
linkDebitAccountComboBox.setCellFactory(cellFactory);
|
||||
linkDebitAccountComboBox.setButtonCell(cellFactory.call(null));
|
||||
linkCreditAccountComboBox.setCellFactory(cellFactory);
|
||||
linkCreditAccountComboBox.setButtonCell(cellFactory.call(null));
|
||||
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
updateLinkAccountComboBoxes(newValue);
|
||||
});
|
||||
}
|
||||
|
||||
@FXML public void save() {
|
||||
// TODO: Validate data!
|
||||
|
||||
LocalDateTime timestamp = parseTimestamp();
|
||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||
Currency currency = currencyChoiceBox.getValue();
|
||||
String description = descriptionField.getText().strip();
|
||||
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
|
||||
Transaction transaction = new Transaction(timestamp, amount, currency, description);
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
repo.insert(transaction, affectedAccounts);
|
||||
});
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
private void resetForm() {
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
amountField.setText("0");
|
||||
Thread.ofVirtual().start(() -> {
|
||||
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
|
||||
var currencies = repo.findAllUsedCurrencies().stream()
|
||||
.sorted(Comparator.comparing(Currency::getCurrencyCode))
|
||||
.toList();
|
||||
Platform.runLater(() -> {
|
||||
currencyChoiceBox.getItems().setAll(currencies);
|
||||
// TODO: cache most-recent currency for the app (maybe for different contexts).
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Map<Long, AccountEntry.Type> getSelectedAccounts() {
|
||||
Account debitAccount = linkDebitAccountComboBox.getValue();
|
||||
Account creditAccount = linkCreditAccountComboBox.getValue();
|
||||
Map<Long, AccountEntry.Type> accountsMap = new HashMap<>();
|
||||
if (debitAccount != null) accountsMap.put(debitAccount.getId(), AccountEntry.Type.DEBIT);
|
||||
if (creditAccount != null) accountsMap.put(creditAccount.getId(), AccountEntry.Type.CREDIT);
|
||||
return accountsMap;
|
||||
}
|
||||
|
||||
private LocalDateTime parseTimestamp() {
|
||||
List<DateTimeFormatter> formatters = List.of(
|
||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
|
||||
DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
|
||||
DateTimeFormatter.ofPattern("d/M/yyyy H:mm:ss")
|
||||
);
|
||||
for (var formatter : formatters) {
|
||||
try {
|
||||
return formatter.parse(timestampField.getText(), LocalDateTime::from);
|
||||
} catch (DateTimeException e) {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void updateLinkAccountComboBoxes(Currency currency) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
|
||||
List<Account> availableAccounts = new ArrayList<>();
|
||||
if (currency != null) availableAccounts.addAll(repo.findAllByCurrency(currency));
|
||||
availableAccounts.add(null);
|
||||
Platform.runLater(() -> {
|
||||
linkDebitAccountComboBox.getItems().clear();
|
||||
linkDebitAccountComboBox.getItems().addAll(availableAccounts);
|
||||
linkDebitAccountComboBox.getSelectionModel().selectLast();
|
||||
linkDebitAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
|
||||
|
||||
linkCreditAccountComboBox.getItems().clear();
|
||||
linkCreditAccountComboBox.getItems().addAll(availableAccounts);
|
||||
linkCreditAccountComboBox.getSelectionModel().selectLast();
|
||||
linkCreditAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void onLinkedAccountsUpdated() {
|
||||
Account debitAccount = linkDebitAccountComboBox.getValue();
|
||||
Account creditAccount = linkCreditAccountComboBox.getValue();
|
||||
if (debitAccount == null && creditAccount == null) {
|
||||
linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect.");
|
||||
} else if (debitAccount != null && creditAccount != null && debitAccount.getId() == creditAccount.getId()) {
|
||||
linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit.");
|
||||
} else {
|
||||
linkedAccountsErrorLabel.setText(null);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,19 +3,24 @@ package com.andrewlalis.perfin.control;
|
|||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.ChoiceBox;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class EditAccountController implements RouteSelectionListener {
|
||||
private Account account;
|
||||
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
|
||||
|
||||
@FXML
|
||||
public Label titleLabel;
|
||||
@FXML
|
||||
|
@ -26,10 +31,10 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
public ComboBox<Currency> accountCurrencyComboBox;
|
||||
@FXML
|
||||
public ChoiceBox<AccountType> accountTypeChoiceBox;
|
||||
|
||||
private boolean editingNewAccount() {
|
||||
return account == null;
|
||||
}
|
||||
@FXML
|
||||
public VBox initialBalanceContent;
|
||||
@FXML
|
||||
public TextField initialBalanceField;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
|
@ -47,12 +52,16 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
accountTypeChoiceBox.getItems().add(AccountType.SAVINGS);
|
||||
accountTypeChoiceBox.getItems().add(AccountType.CREDIT_CARD);
|
||||
accountTypeChoiceBox.getSelectionModel().select(AccountType.CHECKING);
|
||||
|
||||
initialBalanceContent.visibleProperty().bind(creatingNewAccount);
|
||||
initialBalanceContent.managedProperty().bind(creatingNewAccount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
this.account = (Account) context;
|
||||
if (editingNewAccount()) {
|
||||
creatingNewAccount.set(account == null);
|
||||
if (creatingNewAccount.get()) {
|
||||
titleLabel.setText("Editing New Account");
|
||||
} else {
|
||||
titleLabel.setText("Editing Account: " + account.getName());
|
||||
|
@ -62,19 +71,33 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
|
||||
@FXML
|
||||
public void save() {
|
||||
try (var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository()) {
|
||||
if (editingNewAccount()) {
|
||||
try (
|
||||
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
|
||||
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
|
||||
) {
|
||||
if (creatingNewAccount.get()) {
|
||||
String name = accountNameField.getText().strip();
|
||||
String number = accountNumberField.getText().strip();
|
||||
AccountType type = accountTypeChoiceBox.getValue();
|
||||
Currency currency = accountCurrencyComboBox.getValue();
|
||||
Account newAccount = new Account(type, number, name, currency);
|
||||
long id = accountRepo.insert(newAccount);
|
||||
Account savedAccount = accountRepo.findById(id).orElseThrow();
|
||||
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
||||
|
||||
// Once we create the new account, go to the account.
|
||||
router.getHistory().clear();
|
||||
router.navigate("account", savedAccount);
|
||||
Alert confirm = new Alert(
|
||||
Alert.AlertType.CONFIRMATION,
|
||||
"Are you sure you want to create this account?"
|
||||
);
|
||||
Optional<ButtonType> result = confirm.showAndWait();
|
||||
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
|
||||
if (success) {
|
||||
Account newAccount = new Account(type, number, name, currency);
|
||||
long id = accountRepo.insert(newAccount);
|
||||
Account savedAccount = accountRepo.findById(id).orElseThrow();
|
||||
balanceRepo.insert(new BalanceRecord(id, initialBalance, savedAccount.getCurrency()));
|
||||
|
||||
// Once we create the new account, go to the account.
|
||||
router.getHistory().clear();
|
||||
router.navigate("account", savedAccount);
|
||||
}
|
||||
} else {
|
||||
System.out.println("Updating account " + account.getName());
|
||||
account.setName(accountNameField.getText().strip());
|
||||
|
@ -97,11 +120,12 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
}
|
||||
|
||||
public void resetForm() {
|
||||
if (account == null) {
|
||||
if (creatingNewAccount.get()) {
|
||||
accountNameField.setText("");
|
||||
accountNumberField.setText("");
|
||||
accountTypeChoiceBox.getSelectionModel().selectFirst();
|
||||
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
|
||||
initialBalanceField.setText(String.format("%.02f", 0f));
|
||||
} else {
|
||||
accountNameField.setText(account.getName());
|
||||
accountNumberField.setText(account.getAccountNumber());
|
||||
|
|
|
@ -21,6 +21,7 @@ public class MainViewController {
|
|||
public void initialize() {
|
||||
AnchorPaneRouterView routerView = (AnchorPaneRouterView) router.getView();
|
||||
mainContainer.setCenter(routerView.getAnchorPane());
|
||||
|
||||
// Set up a simple breadcrumb display in the top bar.
|
||||
BindingUtil.mapContent(
|
||||
breadcrumbHBox.getChildren(),
|
||||
|
@ -37,12 +38,6 @@ public class MainViewController {
|
|||
router.navigate("accounts");
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void goToAccounts() {
|
||||
router.getHistory().clear();
|
||||
router.navigate("accounts");
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void goBack() {
|
||||
router.navigateBack();
|
||||
|
@ -52,4 +47,16 @@ public class MainViewController {
|
|||
public void goForward() {
|
||||
router.navigateForward();
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void goToAccounts() {
|
||||
router.getHistory().clear();
|
||||
router.navigate("accounts");
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void goToTransactions() {
|
||||
router.getHistory().clear();
|
||||
router.navigate("transactions");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.control.component.TransactionTile;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class TransactionsViewController implements RouteSelectionListener {
|
||||
@FXML
|
||||
public VBox transactionsVBox;
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
refreshTransactions();
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void addTransaction() {
|
||||
router.navigate("create-transaction");
|
||||
}
|
||||
|
||||
private void refreshTransactions() {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp")));
|
||||
var components = page.items().stream().map(transaction -> new TransactionTile(transaction, this::refreshTransactions)).toList();
|
||||
Platform.runLater(() -> transactionsVBox.getChildren().setAll(components));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package com.andrewlalis.perfin.control.component;
|
||||
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
/**
|
||||
* A compact tile that displays information about an account.
|
||||
*/
|
||||
public class AccountTile extends BorderPane {
|
||||
public final Label accountNumberLabel = newPropertyValue();
|
||||
public final Label accountBalanceLabel = newPropertyValue();
|
||||
public final VBox accountNameBox = new VBox();
|
||||
public final Label accountNameLabel = newPropertyValue();
|
||||
|
||||
private static final Map<AccountType, Color> ACCOUNT_TYPE_COLORS = Map.of(
|
||||
AccountType.CHECKING, Color.rgb(214, 222, 255),
|
||||
AccountType.SAVINGS, Color.rgb(219, 255, 214),
|
||||
AccountType.CREDIT_CARD, Color.rgb(255, 250, 214)
|
||||
);
|
||||
|
||||
public AccountTile(Account account) {
|
||||
setPrefWidth(300.0);
|
||||
setPrefHeight(100.0);
|
||||
setStyle("""
|
||||
-fx-border-color: lightgray;
|
||||
-fx-border-width: 1px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 5px;
|
||||
-fx-padding: 5px;
|
||||
-fx-cursor: hand;
|
||||
""");
|
||||
Color color = ACCOUNT_TYPE_COLORS.get(account.getType());
|
||||
var fill = new BackgroundFill(color, new CornerRadii(3.0), null);
|
||||
setBackground(new Background(fill));
|
||||
|
||||
accountNameBox.getChildren().setAll(
|
||||
newPropertyLabel("Account Name"),
|
||||
accountNameLabel
|
||||
);
|
||||
|
||||
Label currencyLabel = new Label(account.getCurrency().getCurrencyCode());
|
||||
Label typeLabel = new Label(account.getType().toString() + " Account");
|
||||
HBox footerHBox = new HBox(currencyLabel, typeLabel);
|
||||
footerHBox.setStyle("-fx-font-size: x-small; -fx-spacing: 3px;");
|
||||
setBottom(footerHBox);
|
||||
|
||||
setCenter(new VBox(
|
||||
newPropertyLabel("Account Number"),
|
||||
accountNumberLabel,
|
||||
newPropertyLabel("Account Balance"),
|
||||
accountBalanceLabel,
|
||||
accountNameBox
|
||||
));
|
||||
|
||||
ObservableValue<Boolean> accountNameTextPresent = accountNameLabel.textProperty().map(t -> t != null && !t.isBlank());
|
||||
accountNameBox.visibleProperty().bind(accountNameTextPresent);
|
||||
accountNameBox.managedProperty().bind(accountNameTextPresent);
|
||||
|
||||
accountNumberLabel.setText(account.getAccountNumber());
|
||||
accountNameLabel.setText(account.getName());
|
||||
accountBalanceLabel.setText("Loading balance...");
|
||||
accountBalanceLabel.setDisable(true);
|
||||
Profile.getCurrent().getDataSource().getAccountBalanceText(account, balanceText -> {
|
||||
accountBalanceLabel.setText(balanceText);
|
||||
accountBalanceLabel.setDisable(false);
|
||||
});
|
||||
|
||||
this.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
||||
router.navigate("account", account);
|
||||
});
|
||||
}
|
||||
|
||||
private static Label newPropertyLabel(String text) {
|
||||
Label lbl = new Label(text);
|
||||
lbl.setStyle("""
|
||||
-fx-font-weight: bold;
|
||||
""");
|
||||
return lbl;
|
||||
}
|
||||
|
||||
private static Label newPropertyValue() {
|
||||
Label lbl = new Label();
|
||||
lbl.setStyle("""
|
||||
-fx-font-family: monospace;
|
||||
-fx-font-size: large;
|
||||
""");
|
||||
return lbl;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package com.andrewlalis.perfin.control.component;
|
||||
|
||||
import com.andrewlalis.perfin.data.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import javafx.util.Pair;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
/**
|
||||
* A tile that displays a transaction's basic information.
|
||||
*/
|
||||
public class TransactionTile extends BorderPane {
|
||||
public TransactionTile(Transaction transaction, Runnable refresh) {
|
||||
setStyle("""
|
||||
-fx-border-color: lightgray;
|
||||
-fx-border-width: 1px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 5px;
|
||||
-fx-padding: 5px;
|
||||
-fx-max-width: 500px;
|
||||
""");
|
||||
|
||||
setTop(getHeader(transaction));
|
||||
setCenter(getBody(transaction));
|
||||
setBottom(getFooter(transaction, refresh));
|
||||
}
|
||||
|
||||
private Node getHeader(Transaction transaction) {
|
||||
Label currencyLabel = new Label(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency()));
|
||||
currencyLabel.setStyle("-fx-font-family: monospace;");
|
||||
HBox headerHBox = new HBox(
|
||||
currencyLabel
|
||||
);
|
||||
headerHBox.setStyle("""
|
||||
-fx-spacing: 3px;
|
||||
""");
|
||||
return headerHBox;
|
||||
}
|
||||
|
||||
private Node getBody(Transaction transaction) {
|
||||
Label descriptionLabel = new Label(transaction.getDescription());
|
||||
descriptionLabel.setWrapText(true);
|
||||
VBox bodyVBox = new VBox(
|
||||
descriptionLabel
|
||||
);
|
||||
getCreditAndDebitAccounts(transaction).thenAccept(accounts -> {
|
||||
Account creditAccount = accounts.getKey();
|
||||
Account debitAccount = accounts.getValue();
|
||||
if (creditAccount != null) {
|
||||
Hyperlink link = new Hyperlink(creditAccount.getShortName());
|
||||
link.setOnAction(event -> router.navigate("account", creditAccount));
|
||||
TextFlow text = new TextFlow(new Text("Credited from"), link);
|
||||
Platform.runLater(() -> bodyVBox.getChildren().add(text));
|
||||
} if (debitAccount != null) {
|
||||
Hyperlink link = new Hyperlink(debitAccount.getShortName());
|
||||
link.setOnAction(event -> router.navigate("account", debitAccount));
|
||||
TextFlow text = new TextFlow(new Text("Debited to"), link);
|
||||
Platform.runLater(() -> bodyVBox.getChildren().add(text));
|
||||
}
|
||||
});
|
||||
return bodyVBox;
|
||||
}
|
||||
|
||||
private Node getFooter(Transaction transaction, Runnable refresh) {
|
||||
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
|
||||
Hyperlink deleteLink = new Hyperlink("Delete this transaction");
|
||||
deleteLink.setOnAction(event -> {
|
||||
var confirmResult = new Alert(Alert.AlertType.CONFIRMATION, "Are you sure you want to delete this transaction?").showAndWait();
|
||||
if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) {
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
repo.delete(transaction.getId());
|
||||
});
|
||||
refresh.run();
|
||||
}
|
||||
});
|
||||
HBox footerHBox = new HBox(
|
||||
timestampLabel,
|
||||
deleteLink
|
||||
);
|
||||
footerHBox.setStyle("""
|
||||
-fx-spacing: 3px;
|
||||
-fx-font-size: small;
|
||||
""");
|
||||
return footerHBox;
|
||||
}
|
||||
|
||||
private CompletableFuture<Pair<Account, Account>> getCreditAndDebitAccounts(Transaction transaction) {
|
||||
CompletableFuture<Pair<Account, Account>> cf = new CompletableFuture<>();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||
var entriesAndAccounts = repo.findEntriesWithAccounts(transaction.getId());
|
||||
AccountEntry creditEntry = entriesAndAccounts.keySet().stream()
|
||||
.filter(entry -> entry.getType() == AccountEntry.Type.CREDIT)
|
||||
.findFirst().orElse(null);
|
||||
AccountEntry debitEntry = entriesAndAccounts.keySet().stream()
|
||||
.filter(entry -> entry.getType() == AccountEntry.Type.DEBIT)
|
||||
.findFirst().orElse(null);
|
||||
cf.complete(new Pair<>(entriesAndAccounts.get(creditEntry), entriesAndAccounts.get(debitEntry)));
|
||||
});
|
||||
});
|
||||
return cf;
|
||||
}
|
||||
}
|
|
@ -1,16 +1,28 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public interface AccountRepository extends AutoCloseable {
|
||||
long insert(Account account);
|
||||
List<Account> findAll();
|
||||
Page<Account> findAll(PageRequest pagination);
|
||||
List<Account> findAllByCurrency(Currency currency);
|
||||
Optional<Account> findById(long id);
|
||||
BigDecimal deriveCurrentBalance(long id);
|
||||
void update(Account account);
|
||||
void delete(Account account);
|
||||
|
||||
BigDecimal deriveBalance(long id, Instant timestamp);
|
||||
default BigDecimal deriveCurrentBalance(long id) {
|
||||
return deriveBalance(id, Instant.now(Clock.systemUTC()));
|
||||
}
|
||||
Set<Currency> findAllUsedCurrencies();
|
||||
}
|
||||
|
|
|
@ -3,5 +3,6 @@ package com.andrewlalis.perfin.data;
|
|||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
|
||||
public interface BalanceRecordRepository extends AutoCloseable {
|
||||
long insert(BalanceRecord record);
|
||||
BalanceRecord findLatestByAccountId(long accountId);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Currency;
|
||||
|
||||
public class CurrencyUtil {
|
||||
public static String formatMoney(BigDecimal amount, Currency currency) {
|
||||
NumberFormat nf = NumberFormat.getCurrencyInstance();
|
||||
nf.setCurrency(currency);
|
||||
nf.setMaximumFractionDigits(currency.getDefaultFractionDigits());
|
||||
nf.setMinimumFractionDigits(currency.getDefaultFractionDigits());
|
||||
BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
|
||||
return nf.format(displayValue);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,18 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import javafx.application.Platform;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Currency;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public interface DataSource {
|
||||
AccountRepository getAccountRepository();
|
||||
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
|
||||
|
@ -10,4 +23,34 @@ public interface DataSource {
|
|||
default void useBalanceRecordRepository(ThrowableConsumer<BalanceRecordRepository> repoConsumer) {
|
||||
DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer);
|
||||
}
|
||||
|
||||
TransactionRepository getTransactionRepository();
|
||||
default void useTransactionRepository(ThrowableConsumer<TransactionRepository> repoConsumer) {
|
||||
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
|
||||
}
|
||||
|
||||
// Utility methods:
|
||||
|
||||
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
useAccountRepository(repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(account.getId());
|
||||
Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(balance, account.getCurrency())));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
default Map<Currency, BigDecimal> getCombinedAccountBalances() {
|
||||
try (var accountRepo = getAccountRepository()) {
|
||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
|
||||
Map<Currency, BigDecimal> totals = new HashMap<>();
|
||||
for (var account : accounts) {
|
||||
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
|
||||
totals.put(account.getCurrency(), currencyTotal.add(accountRepo.deriveCurrentBalance(account.getId())));
|
||||
}
|
||||
return totals;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class DateUtil {
|
||||
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT_WITH_ZONE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
|
||||
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT_PRECISE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
public static DateTimeFormatter DEFAULT_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
public static String formatUTCAsLocalWithZone(LocalDateTime utcTimestamp) {
|
||||
return utcTimestamp.atOffset(ZoneOffset.UTC)
|
||||
.atZoneSameInstant(ZoneId.systemDefault())
|
||||
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
|
||||
import java.sql.*;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
@ -42,6 +45,15 @@ public final class DbUtil {
|
|||
return findAll(conn, query, Collections.emptyList(), mapper);
|
||||
}
|
||||
|
||||
public static <T> Page<T> findAll(Connection conn, String query, PageRequest pagination, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
List<T> items = findAll(conn, query + ' ' + pagination.toSQL(), args, mapper);
|
||||
return new Page<>(items, pagination);
|
||||
}
|
||||
|
||||
public static <T> Page<T> findAll(Connection conn, String query, PageRequest pagination, ResultSetMapper<T> mapper) {
|
||||
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
|
||||
}
|
||||
|
||||
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
|
@ -88,6 +100,10 @@ public final class DbUtil {
|
|||
return Timestamp.from(Instant.now(Clock.systemUTC()));
|
||||
}
|
||||
|
||||
public static Timestamp timestampFromInstant(Instant i) {
|
||||
return Timestamp.from(i);
|
||||
}
|
||||
|
||||
public static LocalDateTime utcLDTFromTimestamp(Timestamp ts) {
|
||||
return ts.toInstant().atOffset(ZoneOffset.UTC).toLocalDateTime();
|
||||
}
|
||||
|
@ -99,4 +115,27 @@ public final class DbUtil {
|
|||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void doTransaction(Connection conn, SQLRunnable runnable) {
|
||||
try {
|
||||
conn.setAutoCommit(false);
|
||||
runnable.run();
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
conn.rollback();
|
||||
} catch (SQLException se) {
|
||||
System.err.println("ERROR: Failed to rollback after a failed transaction!");
|
||||
se.printStackTrace(System.err);
|
||||
throw new UncheckedSqlException(se);
|
||||
}
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
try {
|
||||
conn.setAutoCommit(true);
|
||||
} catch (SQLException e) {
|
||||
System.err.println("ERROR: Failed to set auto-commit to true after transaction!");
|
||||
e.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package com.andrewlalis.perfin.data;
|
|||
|
||||
import java.sql.SQLException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface SqlRunnable {
|
||||
public interface SQLRunnable {
|
||||
void run() throws SQLException;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public interface TransactionRepository extends AutoCloseable {
|
||||
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
|
||||
Page<Transaction> findAll(PageRequest pagination);
|
||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
|
||||
void delete(long transactionId);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
|
||||
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
|
||||
@Override
|
||||
public List<AccountEntry> findAllByAccountId(long accountId) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? ORDER BY timestamp DESC",
|
||||
List.of(accountId),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
public static AccountEntry parse(ResultSet rs) throws SQLException {
|
||||
return new AccountEntry(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
rs.getLong("account_id"),
|
||||
rs.getLong("transaction_id"),
|
||||
rs.getBigDecimal("amount"),
|
||||
AccountEntry.Type.valueOf(rs.getString("type")),
|
||||
Currency.getInstance(rs.getString("currency"))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,17 +3,21 @@ package com.andrewlalis.perfin.data.impl;
|
|||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.data.UncheckedSqlException;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
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 java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
|
||||
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||
@Override
|
||||
|
@ -32,8 +36,18 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
|||
}
|
||||
|
||||
@Override
|
||||
public List<Account> findAll() {
|
||||
return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount);
|
||||
public Page<Account> findAll(PageRequest pagination) {
|
||||
return DbUtil.findAll(conn, "SELECT * FROM account", pagination, JdbcAccountRepository::parseAccount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Account> findAllByCurrency(Currency currency) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account WHERE currency = ? ORDER BY created_at",
|
||||
List.of(currency.getCurrencyCode()),
|
||||
JdbcAccountRepository::parseAccount
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -42,9 +56,69 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
|||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal deriveCurrentBalance(long id) {
|
||||
// TODO: Implement this!
|
||||
return BigDecimal.valueOf(0, 4);
|
||||
public BigDecimal deriveBalance(long id, Instant timestamp) {
|
||||
// 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(id, DbUtil.timestampFromInstant(timestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
if (closestPastRecord.isPresent()) {
|
||||
// Then find any entries on the account since that balance record and the timestamp.
|
||||
List<AccountEntry> accountEntries = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
|
||||
List.of(
|
||||
id,
|
||||
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
|
||||
DbUtil.timestampFromInstant(timestamp)
|
||||
),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
// Apply all entries to the most recent known balance to obtain the balance at this point.
|
||||
BigDecimal currentBalance = closestPastRecord.get().getBalance();
|
||||
for (var entry : accountEntries) {
|
||||
currentBalance = currentBalance.add(entry.getSignedAmount());
|
||||
}
|
||||
return currentBalance;
|
||||
} 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(id, DbUtil.timestampFromInstant(timestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
if (closestFutureRecord.isEmpty()) {
|
||||
throw new IllegalStateException("No balance record exists for account " + id);
|
||||
}
|
||||
// Now find any entries on the account from the timestamp until that balance record.
|
||||
List<AccountEntry> accountEntries = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC",
|
||||
List.of(
|
||||
id,
|
||||
DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()),
|
||||
DbUtil.timestampFromInstant(timestamp)
|
||||
),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
BigDecimal currentBalance = closestFutureRecord.get().getBalance();
|
||||
for (var entry : accountEntries) {
|
||||
currentBalance = currentBalance.subtract(entry.getSignedAmount());
|
||||
}
|
||||
return currentBalance;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Currency> findAllUsedCurrencies() {
|
||||
return new HashSet<>(DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT currency FROM account ORDER BY currency ASC",
|
||||
rs -> Currency.getInstance(rs.getString(1))
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -73,7 +147,7 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
|
|||
}
|
||||
}
|
||||
|
||||
private static Account parseAccount(ResultSet rs) throws SQLException {
|
||||
public static Account parseAccount(ResultSet rs) throws SQLException {
|
||||
long id = rs.getLong("id");
|
||||
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
|
||||
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());
|
||||
|
|
|
@ -11,6 +11,20 @@ import java.util.Currency;
|
|||
import java.util.List;
|
||||
|
||||
public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRecordRepository {
|
||||
@Override
|
||||
public long insert(BalanceRecord record) {
|
||||
return DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcNow(),
|
||||
record.getAccountId(),
|
||||
record.getBalance(),
|
||||
record.getCurrency().getCurrencyCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BalanceRecord findLatestByAccountId(long accountId) {
|
||||
return DbUtil.findOne(
|
||||
|
@ -26,7 +40,7 @@ public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRec
|
|||
conn.close();
|
||||
}
|
||||
|
||||
private static BalanceRecord parse(ResultSet rs) throws SQLException {
|
||||
public static BalanceRecord parse(ResultSet rs) throws SQLException {
|
||||
return new BalanceRecord(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.UncheckedSqlException;
|
||||
import com.andrewlalis.perfin.data.*;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
|
@ -37,4 +34,9 @@ public class JdbcDataSource implements DataSource {
|
|||
public BalanceRecordRepository getBalanceRecordRepository() {
|
||||
return new JdbcBalanceRecordRepository(getConnection());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionRepository getTransactionRepository() {
|
||||
return new JdbcTransactionRepository(getConnection());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.DbUtil;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
|
||||
@Override
|
||||
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
|
||||
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||
AtomicLong transactionId = new AtomicLong(-1);
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
// First insert the transaction itself, then add account entries, referencing this transaction.
|
||||
transactionId.set(DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
||||
List.of(
|
||||
timestamp,
|
||||
transaction.getAmount(),
|
||||
transaction.getCurrency().getCurrencyCode(),
|
||||
transaction.getDescription()
|
||||
)
|
||||
));
|
||||
// Now insert an account entry for each affected account.
|
||||
try (var stmt = conn.prepareStatement(
|
||||
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)) {
|
||||
for (var entry : accountsMap.entrySet()) {
|
||||
long accountId = entry.getKey();
|
||||
AccountEntry.Type entryType = entry.getValue();
|
||||
DbUtil.setArgs(stmt, List.of(
|
||||
timestamp,
|
||||
accountId,
|
||||
transactionId.get(),
|
||||
transaction.getAmount(),
|
||||
entryType.name(),
|
||||
transaction.getCurrency().getCurrencyCode()
|
||||
));
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
return transactionId.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<Transaction> findAll(PageRequest pagination) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM transaction",
|
||||
pagination,
|
||||
JdbcTransactionRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination) {
|
||||
String idsStr = accountIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
String query = String.format("""
|
||||
SELECT *
|
||||
FROM transaction
|
||||
LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id
|
||||
WHERE account_entry.account_id IN (%s)
|
||||
""", idsStr);
|
||||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId) {
|
||||
List<AccountEntry> entries = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_entry WHERE transaction_id = ?",
|
||||
List.of(transactionId),
|
||||
JdbcAccountEntryRepository::parse
|
||||
);
|
||||
Map<AccountEntry, Account> map = new HashMap<>();
|
||||
for (var entry : entries) {
|
||||
Account account = DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM account WHERE id = ?",
|
||||
List.of(entry.getAccountId()),
|
||||
JdbcAccountRepository::parseAccount
|
||||
).orElseThrow();
|
||||
map.put(entry, account);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(long transactionId) {
|
||||
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
public static Transaction parse(ResultSet rs) throws SQLException {
|
||||
return new Transaction(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
rs.getBigDecimal("amount"),
|
||||
Currency.getInstance(rs.getString("currency")),
|
||||
rs.getString("description")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.andrewlalis.perfin.data.pagination;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record Page<T>(List<T> items, PageRequest pagination) {}
|
|
@ -0,0 +1,43 @@
|
|||
package com.andrewlalis.perfin.data.pagination;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public record PageRequest(
|
||||
int page,
|
||||
int size,
|
||||
List<Sort> sorts
|
||||
) {
|
||||
public static PageRequest of(int page, int size, Sort... sorts) {
|
||||
return new PageRequest(page, size, Arrays.asList(sorts));
|
||||
}
|
||||
|
||||
public static PageRequest unpaged(Sort... sorts) {
|
||||
return new PageRequest(-1, -1, Arrays.asList(sorts));
|
||||
}
|
||||
|
||||
public PageRequest next() {
|
||||
return new PageRequest(page + 1, size, sorts);
|
||||
}
|
||||
|
||||
public PageRequest previous() {
|
||||
return new PageRequest(page - 1, size, sorts);
|
||||
}
|
||||
|
||||
public String toSQL() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!sorts.isEmpty()) {
|
||||
sb.append("ORDER BY ");
|
||||
sb.append(sorts.stream().map(Sort::toSQL).collect(Collectors.joining(", ")));
|
||||
sb.append(' ');
|
||||
}
|
||||
if (page == -1 && size == -1) {
|
||||
// Unpaged request, so return the string without any offset/limit.
|
||||
return sb.toString().strip();
|
||||
}
|
||||
long offset = (long) page * size;
|
||||
sb.append("LIMIT ").append(size).append(" OFFSET ").append(offset);
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package com.andrewlalis.perfin.data.pagination;
|
||||
|
||||
public record Sort(String property, Direction direction) {
|
||||
public enum Direction {ASC, DESC}
|
||||
|
||||
public static Sort asc(String property) {
|
||||
return new Sort(property, Direction.ASC);
|
||||
}
|
||||
|
||||
public static Sort desc(String property) {
|
||||
return new Sort(property, Direction.DESC);
|
||||
}
|
||||
|
||||
public String toSQL() {
|
||||
return property + " " + direction.name();
|
||||
}
|
||||
}
|
|
@ -40,6 +40,16 @@ public class Account {
|
|||
return accountNumber;
|
||||
}
|
||||
|
||||
public String getAccountNumberSuffix() {
|
||||
int suffixLength = Math.min(4, accountNumber.length());
|
||||
return "..." + accountNumber.substring(accountNumber.length() - suffixLength);
|
||||
}
|
||||
|
||||
public String getShortName() {
|
||||
String numberSuffix = getAccountNumberSuffix();
|
||||
return name + " (" + numberSuffix + ")";
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
|
|
@ -53,4 +53,44 @@ public class AccountEntry {
|
|||
this.type = type;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public AccountEntry(long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) {
|
||||
this.accountId = accountId;
|
||||
this.transactionId = transactionId;
|
||||
this.amount = amount;
|
||||
this.type = type;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public long getTransactionId() {
|
||||
return transactionId;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public BigDecimal getSignedAmount() {
|
||||
return type == Type.DEBIT ? amount : amount.negate();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,4 +24,30 @@ public class BalanceRecord {
|
|||
this.balance = balance;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public BalanceRecord(long accountId, BigDecimal balance, Currency currency) {
|
||||
this.accountId = accountId;
|
||||
this.balance = balance;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public BigDecimal getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,9 @@ import java.util.Currency;
|
|||
|
||||
/**
|
||||
* A transaction is a permanent record of a transfer of funds between two
|
||||
* accounts.
|
||||
* accounts. Its amount is always recorded as an absolute value, and its
|
||||
* actual positive/negative effect is determined by the associated account
|
||||
* entries that apply this transaction's amount to one or more accounts.
|
||||
*/
|
||||
public class Transaction {
|
||||
private long id;
|
||||
|
@ -23,4 +25,31 @@ public class Transaction {
|
|||
this.currency = currency;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Transaction(LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
|
||||
this.timestamp = timestamp;
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package com.andrewlalis.perfin.view;
|
||||
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.util.Callback;
|
||||
|
||||
public class AccountComboBoxCellFactory implements Callback<ListView<Account>, ListCell<Account>> {
|
||||
public static class AccountListCell extends ListCell<Account> {
|
||||
private final Label label = new Label();
|
||||
|
||||
public AccountListCell() {
|
||||
label.setStyle("-fx-text-fill: black;");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Account item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (item == null || empty) {
|
||||
label.setText("None");
|
||||
} else {
|
||||
label.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
||||
}
|
||||
setGraphic(label);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ListCell<Account> call(ListView<Account> param) {
|
||||
return new AccountListCell();
|
||||
}
|
||||
}
|
|
@ -11,5 +11,7 @@ module com.andrewlalis.perfin {
|
|||
|
||||
exports com.andrewlalis.perfin to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.view to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.model to javafx.graphics;
|
||||
opens com.andrewlalis.perfin.control to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.control.component to javafx.fxml;
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
|
||||
<VBox
|
||||
styleClass="account-tile-container"
|
||||
prefHeight="100.0"
|
||||
prefWidth="300.0"
|
||||
stylesheets="@style/account-tile.css"
|
||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="com.andrewlalis.perfin.control.AccountTileController"
|
||||
fx:id="container"
|
||||
>
|
||||
<Label text="Account Number" styleClass="property-label"/>
|
||||
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
|
||||
<Label text="Account Balance" styleClass="property-label"/>
|
||||
<Label fx:id="accountBalanceLabel" styleClass="property-value" text="account balance placeholder" />
|
||||
<VBox fx:id="accountNameBox">
|
||||
<Label text="Account Name" styleClass="property-label"/>
|
||||
<Label fx:id="accountNameLabel" styleClass="property-value" text="account name placeholder"/>
|
||||
</VBox>
|
||||
</VBox>
|
|
@ -30,6 +30,10 @@
|
|||
<Label text="Created At"/>
|
||||
<TextField fx:id="accountCreatedAtField" editable="false"/>
|
||||
</VBox>
|
||||
<VBox styleClass="account-property-box">
|
||||
<Label text="Current Balance"/>
|
||||
<TextField fx:id="accountBalanceField" editable="false"/>
|
||||
</VBox>
|
||||
</VBox>
|
||||
</center>
|
||||
<right>
|
||||
|
|
|
@ -1,41 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.FlowPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane
|
||||
minHeight="400.0"
|
||||
minWidth="600.0"
|
||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="com.andrewlalis.perfin.control.AccountsViewController"
|
||||
fx:id="mainContainer"
|
||||
stylesheets="@style/accounts-view.css"
|
||||
>
|
||||
<!-- <top>-->
|
||||
<!-- <MenuBar BorderPane.alignment="CENTER">-->
|
||||
<!-- <Menu mnemonicParsing="false" text="File">-->
|
||||
<!-- <MenuItem mnemonicParsing="false" text="Close" />-->
|
||||
<!-- </Menu>-->
|
||||
<!-- <Menu mnemonicParsing="false" text="Edit">-->
|
||||
<!-- <MenuItem mnemonicParsing="false" text="Delete" />-->
|
||||
<!-- </Menu>-->
|
||||
<!-- <Menu mnemonicParsing="false" text="Help">-->
|
||||
<!-- <MenuItem mnemonicParsing="false" text="About" />-->
|
||||
<!-- </Menu>-->
|
||||
<!-- </MenuBar>-->
|
||||
<!-- </top>-->
|
||||
<center>
|
||||
<VBox>
|
||||
<HBox styleClass="actionsBar">
|
||||
<Button text="Add an Account" onAction="#createNewAccount"/>
|
||||
</HBox>
|
||||
<FlowPane fx:id="accountsPane" BorderPane.alignment="TOP_LEFT" vgap="5" hgap="5"/>
|
||||
<Label fx:id="noAccountsLabel" BorderPane.alignment="TOP_LEFT" text="No accounts have been added to this profile."/>
|
||||
</VBox>
|
||||
</center>
|
||||
<top>
|
||||
<HBox styleClass="actionsBar">
|
||||
<Button text="Add an Account" onAction="#createNewAccount"/>
|
||||
<Label fx:id="totalLabel"/>
|
||||
</HBox>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<ScrollPane fitToHeight="true" fitToWidth="true" VBox.vgrow="ALWAYS">
|
||||
<FlowPane fx:id="accountsPane" BorderPane.alignment="TOP_LEFT" vgap="5" hgap="5"/>
|
||||
</ScrollPane>
|
||||
<Label fx:id="noAccountsLabel" BorderPane.alignment="TOP_LEFT" text="No accounts have been added to this profile."/>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.CreateTransactionController"
|
||||
stylesheets="@style/create-transaction.css"
|
||||
>
|
||||
<center>
|
||||
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||
<VBox styleClass="form-container">
|
||||
<VBox>
|
||||
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
||||
<Label fx:id="timestampInvalidLabel" text="Invalid timestamp." styleClass="error-text"/>
|
||||
<Label fx:id="timestampFutureLabel" text="Timestamp cannot be in the future." styleClass="error-text"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="amountField" styleClass="mono-font"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
|
||||
<ChoiceBox fx:id="currencyChoiceBox"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||
<Label text="Maximum of 256 characters." styleClass="small-text"/>
|
||||
<TextArea fx:id="descriptionField" styleClass="mono-font"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<HBox spacing="3">
|
||||
<VBox>
|
||||
<Label text="Debited Account" labelFor="${linkDebitAccountComboBox}" styleClass="bold-text"/>
|
||||
<ComboBox fx:id="linkDebitAccountComboBox">
|
||||
<tooltip><Tooltip text="The account whose assets will increase as a result of this transaction."/></tooltip>
|
||||
</ComboBox>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<Label text="Credited Account" labelFor="${linkCreditAccountComboBox}" styleClass="bold-text"/>
|
||||
<ComboBox fx:id="linkCreditAccountComboBox">
|
||||
<tooltip><Tooltip text="The account whose assets will decrease as a result of this transaction."/></tooltip>
|
||||
</ComboBox>
|
||||
</VBox>
|
||||
</HBox>
|
||||
<Label fx:id="linkedAccountsErrorLabel" styleClass="error-text" wrapText="true"/>
|
||||
<Label
|
||||
text="Every transaction must be linked to one of your accounts. In the case of transfer between accounts, then both the sender and receiver accounts should be linked."
|
||||
styleClass="small-text"
|
||||
style="-fx-min-width: 100px; -fx-min-height: 100px; -fx-alignment: top-left"
|
||||
wrapText="true"
|
||||
VBox.vgrow="NEVER"
|
||||
/>
|
||||
</VBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
</center>
|
||||
<bottom>
|
||||
<HBox styleClass="buttons-container">
|
||||
<Button text="Save" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
</BorderPane>
|
|
@ -13,22 +13,28 @@
|
|||
<Label fx:id="titleLabel" text="Edit Account"/>
|
||||
</top>
|
||||
<center>
|
||||
<GridPane BorderPane.alignment="CENTER" styleClass="fields-grid">
|
||||
<GridPane BorderPane.alignment="TOP_LEFT" styleClass="fields-grid">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" minWidth="10.0" />
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" />
|
||||
</columnConstraints>
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="0" text="Name"/>
|
||||
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0" text="Bleh"/>
|
||||
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="1" text="Account Number"/>
|
||||
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1" text="1234"/>
|
||||
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="2" text="Currency"/>
|
||||
<ComboBox fx:id="accountCurrencyComboBox" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="3" text="Account Type"/>
|
||||
<ChoiceBox fx:id="accountTypeChoiceBox" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
|
||||
|
||||
<VBox fx:id="initialBalanceContent" GridPane.columnIndex="0" GridPane.rowIndex="4" GridPane.columnSpan="2">
|
||||
<Separator/>
|
||||
<Label text="Initial Balance" style="-fx-font-weight: bold;"/>
|
||||
<TextField fx:id="initialBalanceField"/>
|
||||
</VBox>
|
||||
</GridPane>
|
||||
</center>
|
||||
<bottom>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<Button text="Back" onAction="#goBack"/>
|
||||
<Button text="Forward" onAction="#goForward"/>
|
||||
<Button text="Accounts" onAction="#goToAccounts"/>
|
||||
<Button text="Transactions" onAction="#goToTransactions"/>
|
||||
</HBox>
|
||||
<HBox fx:id="breadcrumbHBox"/>
|
||||
<Separator/>
|
||||
|
@ -23,7 +24,6 @@
|
|||
<bottom>
|
||||
<HBox fx:id="mainFooter" spacing="5">
|
||||
<Label text="Perfin v0.0.1"/>
|
||||
<Label text="Copyright Bleh"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
</BorderPane>
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
.account-tile-container {
|
||||
-fx-border-color: lightgray;
|
||||
-fx-border-width: 1px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 5px;
|
||||
-fx-padding: 5px;
|
||||
}
|
||||
|
||||
.account-tile-container:hover {
|
||||
-fx-cursor: hand;
|
||||
}
|
||||
|
||||
.main-label {
|
||||
-fx-font-weight: bold;
|
||||
-fx-font-size: large;
|
||||
}
|
||||
|
||||
.property-label {
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
.property-value {
|
||||
-fx-font-family: monospace;
|
||||
-fx-font-size: large;
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
#accountsPane {
|
||||
-fx-padding: 5px;
|
||||
-fx-padding: 3px;
|
||||
}
|
||||
|
||||
#noAccountsLabel {
|
||||
-fx-padding: 5px;
|
||||
-fx-padding: 3px;
|
||||
}
|
||||
|
||||
.actionsBar {
|
||||
-fx-padding: 5px;
|
||||
-fx-padding: 3px;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
.mono-font {
|
||||
-fx-font-family: monospace;
|
||||
}
|
||||
|
||||
.bold-text {
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
-fx-font-size: small;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
-fx-font-size: small;
|
||||
-fx-text-fill: red;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
-fx-max-width: 500px;
|
||||
-fx-spacing: 3px;
|
||||
-fx-padding: 3px;
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
-fx-padding: 3px;
|
||||
-fx-spacing: 3px;
|
||||
}
|
||||
|
||||
#descriptionField {
|
||||
-fx-pref-height: 100px;
|
||||
-fx-min-height: 100px;
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
-fx-hgap: 3px;
|
||||
-fx-vgap: 3px;
|
||||
-fx-padding: 5px;
|
||||
-fx-max-width: 500px;
|
||||
}
|
||||
|
||||
#titleLabel {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
||||
>
|
||||
<top>
|
||||
<HBox style="-fx-padding: 3px;">
|
||||
<Button text="Add Transaction" onAction="#addTransaction"/>
|
||||
</HBox>
|
||||
</top>
|
||||
<center>
|
||||
<Label text="Center"/>
|
||||
<ScrollPane fitToHeight="true" fitToWidth="true">
|
||||
<VBox fx:id="transactionsVBox" style="-fx-padding: 3px; -fx-spacing: 5px;"/>
|
||||
</ScrollPane>
|
||||
</center>
|
||||
</BorderPane>
|
Loading…
Reference in New Issue