diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 4dc0fca..d512da8 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -7,34 +7,39 @@ import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AccountHistoryView; +import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import javafx.application.Platform; import javafx.beans.binding.BooleanExpression; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.*; +import javafx.beans.value.ObservableValue; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.DatePicker; import javafx.scene.control.Label; import javafx.scene.layout.HBox; +import javafx.scene.text.Text; import java.time.*; import static com.andrewlalis.perfin.PerfinApp.router; public class AccountViewController implements RouteSelectionListener { - private Account account; + private final ObjectProperty accountProperty = new SimpleObjectProperty<>(null); + private final ObservableValue accountArchived = accountProperty.map(a -> a != null && a.isArchived()); + private final StringProperty balanceTextProperty = new SimpleStringProperty(null); @FXML public Label titleLabel; - @FXML public Label accountNameLabel; @FXML public Label accountNumberLabel; @FXML public Label accountCurrencyLabel; @FXML public Label accountCreatedAtLabel; @FXML public Label accountBalanceLabel; - @FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false); + @FXML public PropertiesPane descriptionPane; + @FXML public Text accountDescriptionText; @FXML public AccountHistoryView accountHistory; @@ -44,11 +49,21 @@ public class AccountViewController implements RouteSelectionListener { @FXML public Button balanceCheckerButton; @FXML public void initialize() { + titleLabel.textProperty().bind(accountProperty.map(a -> "Account #" + a.id)); + accountNameLabel.textProperty().bind(accountProperty.map(Account::getName)); + accountNumberLabel.textProperty().bind(accountProperty.map(Account::getAccountNumber)); + accountCurrencyLabel.textProperty().bind(accountProperty.map(a -> a.getCurrency().getDisplayName())); + accountCreatedAtLabel.textProperty().bind(accountProperty.map(a -> DateUtil.formatUTCAsLocalWithZone(a.getCreatedAt()))); + accountDescriptionText.textProperty().bind(accountProperty.map(Account::getDescription)); + var hasDescription = accountProperty.map(a -> a.getDescription() != null); + BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription); + accountBalanceLabel.textProperty().bind(balanceTextProperty); + actionsBox.getChildren().forEach(node -> { Button button = (Button) node; - BooleanExpression buttonActive = accountArchivedProperty; + ObservableValue buttonActive = accountArchived; if (button.getText().equalsIgnoreCase("Unarchive")) { - buttonActive = buttonActive.not(); + buttonActive = BooleanExpression.booleanExpression(buttonActive).not(); } button.disableProperty().bind(buttonActive); button.managedProperty().bind(button.visibleProperty()); @@ -66,41 +81,42 @@ public class AccountViewController implements RouteSelectionListener { .toInstant(); Profile.getCurrent().dataSource().mapRepoAsync( AccountRepository.class, - repo -> repo.deriveBalance(account.id, timestamp) + repo -> repo.deriveBalance(getAccount().id, timestamp) ).thenAccept(balance -> Platform.runLater(() -> { String msg = String.format( "Your balance as of %s is %s, according to Perfin's data.", date, - CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency())) + CurrencyUtil.formatMoney(new MoneyValue(balance, getAccount().getCurrency())) ); Popups.message(balanceCheckerButton, msg); })); }); + + accountProperty.addListener((observable, oldValue, newValue) -> { + accountHistory.clear(); + if (newValue == null) { + balanceTextProperty.set(null); + } else { + accountHistory.setAccountId(newValue.id); + accountHistory.loadMoreHistory(); + Profile.getCurrent().dataSource().getAccountBalanceText(newValue) + .thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s))); + } + }); } @Override public void onRouteSelected(Object context) { - account = (Account) context; - accountArchivedProperty.set(account.isArchived()); - titleLabel.setText("Account #" + account.id); - accountNameLabel.setText(account.getName()); - accountNumberLabel.setText(account.getAccountNumber()); - accountCurrencyLabel.setText(account.getCurrency().getDisplayName()); - accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt())); - Profile.getCurrent().dataSource().getAccountBalanceText(account) - .thenAccept(accountBalanceLabel::setText); - accountHistory.clear(); - accountHistory.setAccountId(account.id); - accountHistory.loadMoreHistory(); + this.accountProperty.set((Account) context); } @FXML public void goToEditPage() { - router.navigate("edit-account", account); + router.navigate("edit-account", getAccount()); } @FXML public void goToCreateBalanceRecord() { - router.navigate("create-balance-record", account); + router.navigate("create-balance-record", getAccount()); } @FXML @@ -114,7 +130,7 @@ public class AccountViewController implements RouteSelectionListener { "later if you need to." ); if (confirmResult) { - Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id)); + Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(getAccount().id)); router.replace("accounts"); } } @@ -126,7 +142,7 @@ public class AccountViewController implements RouteSelectionListener { "status?" ); if (confirm) { - Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id)); + Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(getAccount().id)); router.replace("accounts"); } } @@ -142,8 +158,12 @@ public class AccountViewController implements RouteSelectionListener { "want to hide it." ); if (confirm) { - Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account)); + Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(getAccount())); router.replace("accounts"); } } + + private Account getAccount() { + return accountProperty.get(); + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 7d11807..0f6d631 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -33,20 +33,14 @@ public class EditAccountController implements RouteSelectionListener { private Account account; private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false); - @FXML - public Label titleLabel; - @FXML - public TextField accountNameField; - @FXML - public TextField accountNumberField; - @FXML - public ComboBox accountCurrencyComboBox; - @FXML - public ChoiceBox accountTypeChoiceBox; - @FXML - public PropertiesPane initialBalanceContent; - @FXML - public TextField initialBalanceField; + @FXML public Label titleLabel; + @FXML public TextField accountNameField; + @FXML public TextField accountNumberField; + @FXML public ComboBox accountCurrencyComboBox; + @FXML public ChoiceBox accountTypeChoiceBox; + @FXML public TextArea descriptionField; + @FXML public PropertiesPane initialBalanceContent; + @FXML public TextField initialBalanceField; @FXML public Button saveButton; @@ -66,8 +60,12 @@ public class EditAccountController implements RouteSelectionListener { new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false) ).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty()); + var descriptionValid = new ValidationApplier<>(new PredicateValidator() + .addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.") + ).attach(descriptionField, descriptionField.textProperty()); + // Combine validity of all fields for an expression that determines if the whole form is valid. - BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid.or(creatingNewAccount.not())); + BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid.or(creatingNewAccount.not())).and(descriptionValid); saveButton.disableProperty().bind(formValid.not()); List priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD") @@ -111,6 +109,11 @@ public class EditAccountController implements RouteSelectionListener { String number = accountNumberField.getText().strip(); AccountType type = accountTypeChoiceBox.getValue(); Currency currency = accountCurrencyComboBox.getValue(); + String description = descriptionField.getText(); + if (description != null) { + description = description.strip(); + if (description.isBlank()) description = null; + } try ( var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository() @@ -128,14 +131,14 @@ public class EditAccountController implements RouteSelectionListener { ); boolean success = Popups.confirm(accountNameField, prompt); if (success) { - long id = accountRepo.insert(type, number, name, currency); + long id = accountRepo.insert(type, number, name, currency, description); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); // Once we create the new account, go to the account. Account newAccount = accountRepo.findById(id).orElseThrow(); router.replace("account", newAccount); } } else { - accountRepo.update(account.id, type, number, name, currency); + accountRepo.update(account.id, type, number, name, currency, description); Account updatedAccount = accountRepo.findById(account.id).orElseThrow(); router.replace("account", updatedAccount); } @@ -157,11 +160,13 @@ public class EditAccountController implements RouteSelectionListener { accountTypeChoiceBox.getSelectionModel().selectFirst(); accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD")); initialBalanceField.setText(String.format("%.02f", 0f)); + descriptionField.setText(null); } else { accountNameField.setText(account.getName()); accountNumberField.setText(account.getAccountNumber()); accountTypeChoiceBox.getSelectionModel().select(account.getType()); accountCurrencyComboBox.getSelectionModel().select(account.getCurrency()); + descriptionField.setText(account.getDescription()); } } } diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java index e97bee6..c936f47 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java @@ -16,14 +16,14 @@ import java.util.Optional; import java.util.Set; 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, String description); Page findAll(PageRequest pagination); List findAllOrderedByRecentHistory(); List findTopNOrderedByRecentHistory(int n); List findTopNRecentlyActive(int n, int daysSinceLastActive); List findAllByCurrency(Currency currency); Optional findById(long id); - void update(long accountId, AccountType type, String accountNumber, String name, Currency currency); + void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description); void delete(Account account); void archive(long accountId); void unarchive(long accountId); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index e044517..cec7837 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -23,18 +23,18 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class); @Override - public long insert(AccountType type, String accountNumber, String name, Currency currency) { + public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) { return DbUtil.doTransaction(conn, () -> { + long accountId = DbUtil.insertOne( conn, - "INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)", - List.of( - DbUtil.timestampFromUtcNow(), - type.name(), - accountNumber, - name, - currency.getCurrencyCode() - ) + "INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)", + DbUtil.timestampFromUtcNow(), + type.name(), + accountNumber, + name, + currency.getCurrencyCode(), + description ); // Insert a history item indicating the creation of the account. HistoryRepository historyRepo = new JdbcHistoryRepository(conn); @@ -210,7 +210,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements } @Override - public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency) { + public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description) { DbUtil.doTransaction(conn, () -> { Account account = findById(accountId).orElse(null); if (account == null) return; @@ -231,6 +231,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements DbUtil.updateOne(conn, "UPDATE account SET currency = ? WHERE id = ?", currency.getCurrencyCode(), accountId); updateMessages.add(String.format("Updated account currency from %s to %s.", account.getCurrency(), currency)); } + if (!Objects.equals(account.getDescription(), description)) { + DbUtil.updateOne(conn, "UPDATE account SET description = ? WHERE id = ?", description, accountId); + updateMessages.add("Updated account's description."); + } if (!updateMessages.isEmpty()) { var historyRepo = new JdbcHistoryRepository(conn); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java index 29ef062..5fedc3f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java @@ -28,7 +28,13 @@ public final class DbUtil { } public static void setArgs(PreparedStatement stmt, Object... args) { - setArgs(stmt, List.of(args)); + for (int i = 0; i < args.length; i++) { + try { + stmt.setObject(i + 1, args[i]); + } catch (SQLException e) { + throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args[i], e); + } + } } public static long getGeneratedId(PreparedStatement stmt) { @@ -107,6 +113,11 @@ public final class DbUtil { } public static void updateOne(Connection conn, String query, List args) { + Object[] argsArray = args.toArray(); + updateOne(conn, query, argsArray); + } + + public static void updateOne(Connection conn, String query, Object... args) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); int updateCount = stmt.executeUpdate(); @@ -116,11 +127,12 @@ public final class DbUtil { } } - public static void updateOne(Connection conn, String query, Object... args) { - updateOne(conn, query, List.of(args)); + public static long insertOne(Connection conn, String query, List args) { + Object[] argsArray = args.toArray(); + return insertOne(conn, query, argsArray); } - public static long insertOne(Connection conn, String query, List args) { + public static long insertOne(Connection conn, String query, Object... args) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { setArgs(stmt, args); int result = stmt.executeUpdate(); @@ -131,10 +143,6 @@ public final class DbUtil { } } - public static long insertOne(Connection conn, String query, Object... args) { - return insertOne(conn, query, List.of(args)); - } - public static Timestamp timestampFromUtcLDT(LocalDateTime utc) { return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); } diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index a5ecd4e..6bbe6c0 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -44,6 +44,11 @@ + + +