Refactored the account-view.fxml and associated stuff to include the account's description.

This commit is contained in:
Andrew Lalis 2024-02-11 08:48:46 -05:00
parent b52148fd3b
commit 7f65466d6d
7 changed files with 112 additions and 63 deletions

View File

@ -7,34 +7,39 @@ import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile; 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.AccountHistoryView;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.*;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.DatePicker; import javafx.scene.control.DatePicker;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.text.Text;
import java.time.*; import java.time.*;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountViewController implements RouteSelectionListener { public class AccountViewController implements RouteSelectionListener {
private Account account; private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
@FXML public Label titleLabel; @FXML public Label titleLabel;
@FXML public Label accountNameLabel; @FXML public Label accountNameLabel;
@FXML public Label accountNumberLabel; @FXML public Label accountNumberLabel;
@FXML public Label accountCurrencyLabel; @FXML public Label accountCurrencyLabel;
@FXML public Label accountCreatedAtLabel; @FXML public Label accountCreatedAtLabel;
@FXML public Label accountBalanceLabel; @FXML public Label accountBalanceLabel;
@FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false); @FXML public PropertiesPane descriptionPane;
@FXML public Text accountDescriptionText;
@FXML public AccountHistoryView accountHistory; @FXML public AccountHistoryView accountHistory;
@ -44,11 +49,21 @@ public class AccountViewController implements RouteSelectionListener {
@FXML public Button balanceCheckerButton; @FXML public Button balanceCheckerButton;
@FXML public void initialize() { @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 -> { actionsBox.getChildren().forEach(node -> {
Button button = (Button) node; Button button = (Button) node;
BooleanExpression buttonActive = accountArchivedProperty; ObservableValue<Boolean> buttonActive = accountArchived;
if (button.getText().equalsIgnoreCase("Unarchive")) { if (button.getText().equalsIgnoreCase("Unarchive")) {
buttonActive = buttonActive.not(); buttonActive = BooleanExpression.booleanExpression(buttonActive).not();
} }
button.disableProperty().bind(buttonActive); button.disableProperty().bind(buttonActive);
button.managedProperty().bind(button.visibleProperty()); button.managedProperty().bind(button.visibleProperty());
@ -66,41 +81,42 @@ public class AccountViewController implements RouteSelectionListener {
.toInstant(); .toInstant();
Profile.getCurrent().dataSource().mapRepoAsync( Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class, AccountRepository.class,
repo -> repo.deriveBalance(account.id, timestamp) repo -> repo.deriveBalance(getAccount().id, timestamp)
).thenAccept(balance -> Platform.runLater(() -> { ).thenAccept(balance -> Platform.runLater(() -> {
String msg = String.format( String msg = String.format(
"Your balance as of %s is %s, according to Perfin's data.", "Your balance as of %s is %s, according to Perfin's data.",
date, date,
CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency())) CurrencyUtil.formatMoney(new MoneyValue(balance, getAccount().getCurrency()))
); );
Popups.message(balanceCheckerButton, msg); 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 @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
account = (Account) context; this.accountProperty.set((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();
} }
@FXML @FXML
public void goToEditPage() { public void goToEditPage() {
router.navigate("edit-account", account); router.navigate("edit-account", getAccount());
} }
@FXML public void goToCreateBalanceRecord() { @FXML public void goToCreateBalanceRecord() {
router.navigate("create-balance-record", account); router.navigate("create-balance-record", getAccount());
} }
@FXML @FXML
@ -114,7 +130,7 @@ public class AccountViewController implements RouteSelectionListener {
"later if you need to." "later if you need to."
); );
if (confirmResult) { 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"); router.replace("accounts");
} }
} }
@ -126,7 +142,7 @@ public class AccountViewController implements RouteSelectionListener {
"status?" "status?"
); );
if (confirm) { 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"); router.replace("accounts");
} }
} }
@ -142,8 +158,12 @@ public class AccountViewController implements RouteSelectionListener {
"want to hide it." "want to hide it."
); );
if (confirm) { 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"); router.replace("accounts");
} }
} }
private Account getAccount() {
return accountProperty.get();
}
} }

View File

@ -33,20 +33,14 @@ public class EditAccountController implements RouteSelectionListener {
private Account account; private Account account;
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false); private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
@FXML @FXML public Label titleLabel;
public Label titleLabel; @FXML public TextField accountNameField;
@FXML @FXML public TextField accountNumberField;
public TextField accountNameField; @FXML public ComboBox<Currency> accountCurrencyComboBox;
@FXML @FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
public TextField accountNumberField; @FXML public TextArea descriptionField;
@FXML @FXML public PropertiesPane initialBalanceContent;
public ComboBox<Currency> accountCurrencyComboBox; @FXML public TextField initialBalanceField;
@FXML
public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML
public PropertiesPane initialBalanceContent;
@FXML
public TextField initialBalanceField;
@FXML public Button saveButton; @FXML public Button saveButton;
@ -66,8 +60,12 @@ public class EditAccountController implements RouteSelectionListener {
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false) new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty()); ).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.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. // 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()); saveButton.disableProperty().bind(formValid.not());
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD") List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
@ -111,6 +109,11 @@ public class EditAccountController implements RouteSelectionListener {
String number = accountNumberField.getText().strip(); String number = accountNumberField.getText().strip();
AccountType type = accountTypeChoiceBox.getValue(); AccountType type = accountTypeChoiceBox.getValue();
Currency currency = accountCurrencyComboBox.getValue(); Currency currency = accountCurrencyComboBox.getValue();
String description = descriptionField.getText();
if (description != null) {
description = description.strip();
if (description.isBlank()) description = null;
}
try ( try (
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository() var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
@ -128,14 +131,14 @@ public class EditAccountController implements RouteSelectionListener {
); );
boolean success = Popups.confirm(accountNameField, prompt); boolean success = Popups.confirm(accountNameField, prompt);
if (success) { 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); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
// Once we create the new account, go to the account. // Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow(); Account newAccount = accountRepo.findById(id).orElseThrow();
router.replace("account", newAccount); router.replace("account", newAccount);
} }
} else { } 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(); Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.replace("account", updatedAccount); router.replace("account", updatedAccount);
} }
@ -157,11 +160,13 @@ public class EditAccountController implements RouteSelectionListener {
accountTypeChoiceBox.getSelectionModel().selectFirst(); accountTypeChoiceBox.getSelectionModel().selectFirst();
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD")); accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f)); initialBalanceField.setText(String.format("%.02f", 0f));
descriptionField.setText(null);
} else { } else {
accountNameField.setText(account.getName()); accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber()); accountNumberField.setText(account.getAccountNumber());
accountTypeChoiceBox.getSelectionModel().select(account.getType()); accountTypeChoiceBox.getSelectionModel().select(account.getType());
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency()); accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
descriptionField.setText(account.getDescription());
} }
} }
} }

View File

@ -16,14 +16,14 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
public interface AccountRepository extends Repository, AutoCloseable { 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<Account> findAll(PageRequest pagination); Page<Account> findAll(PageRequest pagination);
List<Account> findAllOrderedByRecentHistory(); List<Account> findAllOrderedByRecentHistory();
List<Account> findTopNOrderedByRecentHistory(int n); List<Account> findTopNOrderedByRecentHistory(int n);
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive); 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 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 delete(Account account);
void archive(long accountId); void archive(long accountId);
void unarchive(long accountId); void unarchive(long accountId);

View File

@ -23,18 +23,18 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class); private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
@Override @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, () -> { return DbUtil.doTransaction(conn, () -> {
long accountId = DbUtil.insertOne( long accountId = DbUtil.insertOne(
conn, conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)", "INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
List.of( DbUtil.timestampFromUtcNow(),
DbUtil.timestampFromUtcNow(), type.name(),
type.name(), accountNumber,
accountNumber, name,
name, currency.getCurrencyCode(),
currency.getCurrencyCode() description
)
); );
// Insert a history item indicating the creation of the account. // Insert a history item indicating the creation of the account.
HistoryRepository historyRepo = new JdbcHistoryRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
@ -210,7 +210,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} }
@Override @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, () -> { DbUtil.doTransaction(conn, () -> {
Account account = findById(accountId).orElse(null); Account account = findById(accountId).orElse(null);
if (account == null) return; 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); 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)); 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()) { if (!updateMessages.isEmpty()) {
var historyRepo = new JdbcHistoryRepository(conn); var historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);

View File

@ -28,7 +28,13 @@ public final class DbUtil {
} }
public static void setArgs(PreparedStatement stmt, Object... args) { 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) { public static long getGeneratedId(PreparedStatement stmt) {
@ -107,6 +113,11 @@ public final class DbUtil {
} }
public static void updateOne(Connection conn, String query, List<Object> args) { public static void updateOne(Connection conn, String query, List<Object> 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)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); setArgs(stmt, args);
int updateCount = stmt.executeUpdate(); int updateCount = stmt.executeUpdate();
@ -116,11 +127,12 @@ public final class DbUtil {
} }
} }
public static void updateOne(Connection conn, String query, Object... args) { public static long insertOne(Connection conn, String query, List<Object> args) {
updateOne(conn, query, List.of(args)); Object[] argsArray = args.toArray();
return insertOne(conn, query, argsArray);
} }
public static long insertOne(Connection conn, String query, List<Object> args) { public static long insertOne(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
setArgs(stmt, args); setArgs(stmt, args);
int result = stmt.executeUpdate(); 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) { public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
} }

View File

@ -44,6 +44,11 @@
</PropertiesPane> </PropertiesPane>
</FlowPane> </FlowPane>
<PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane">
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>
</PropertiesPane>
<!-- Action buttons --> <!-- Action buttons -->
<HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font"> <HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font">
<Button text="Edit" onAction="#goToEditPage"/> <Button text="Edit" onAction="#goToEditPage"/>

View File

@ -32,6 +32,13 @@
<Label text="Account Type" styleClass="bold-text"/> <Label text="Account Type" styleClass="bold-text"/>
<ChoiceBox fx:id="accountTypeChoiceBox"/> <ChoiceBox fx:id="accountTypeChoiceBox"/>
<Label text="Description" styleClass="bold-text"/>
<TextArea
fx:id="descriptionField"
wrapText="true"
style="-fx-pref-height: 100px;-fx-min-height: 100px;"
/>
</PropertiesPane> </PropertiesPane>
<!-- Initial balance content that's only visible when creating a new account. --> <!-- Initial balance content that's only visible when creating a new account. -->