From a9cdc6c41ea658adc416732a1d57e9819cc929b4 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Tue, 20 Feb 2024 17:17:21 -0500 Subject: [PATCH] Added label for confirming line items total, and for setting amount equal to the line items total. --- .../control/EditTransactionController.java | 40 ++++ .../control/TransactionsViewController.java | 7 + .../data/TransactionCategoryRepository.java | 15 +- .../JdbcTransactionCategoryRepository.java | 5 + .../data/search/JdbcTransactionSearcher.java | 187 +++++++++++++++++- src/main/resources/account-view.fxml | 4 +- src/main/resources/edit-transaction.fxml | 8 + 7 files changed, 260 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 14c977f..9ab3fc3 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -18,8 +18,10 @@ import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanExpression; import javafx.beans.property.*; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; @@ -80,6 +82,8 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public Button addLineItemAddButton; @FXML public Button addLineItemCancelButton; @FXML public VBox lineItemsVBox; + @FXML public Label lineItemsValueMatchLabel; + @FXML public Button lineItemsAmountSyncButton; @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false); private final ObservableList lineItems = FXCollections.observableArrayList(); private static long tmpLineItemId = -1L; @@ -379,6 +383,42 @@ public class EditTransactionController implements RouteSelectionListener { lineItems.add(tmpItem); addingLineItemProperty.set(false); }); + + // Logic for showing an indicator when the line items total exactly matches the entered amount. + ListProperty lineItemsProperty = new SimpleListProperty<>(lineItems); + ObservableValue lineItemsTotalValue = lineItemsProperty.map(items -> items.stream() + .map(TransactionLineItem::getTotalValue) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + ObjectProperty amountFieldValue = new SimpleObjectProperty<>(BigDecimal.ZERO); + amountField.textProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + amountFieldValue.set(BigDecimal.ZERO); + } else { + try { + BigDecimal amount = new BigDecimal(newValue); + amountFieldValue.set(amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount); + } catch (NumberFormatException e) { + amountFieldValue.set(BigDecimal.ZERO); + } + } + }); + BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false); + lineItemsTotalValue.addListener((observable, oldValue, newValue) -> { + lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0); + }); + amountFieldValue.addListener((observable, oldValue, newValue) -> { + lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0); + }); + BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not())); + + // Logic for button that syncs line items total to the amount field. + BindingUtil.bindManagedAndVisible(lineItemsAmountSyncButton, lineItemsTotalMatchesAmount.not().and(lineItemsProperty.emptyProperty().not())); + lineItemsAmountSyncButton.setOnAction(event -> amountField.setText( + CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue( + lineItemsTotalValue.getValue(), + currencyChoiceBox.getValue() + )) + )); } private Node createLineItemTile(TransactionLineItem item) { diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 751ba51..068c200 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -213,6 +213,13 @@ public class TransactionsViewController implements RouteSelectionListener { return filters; } + // Temporary utility to try out the new filter builder. + private List tmpFilter() { + return new JdbcTransactionSearcher.FilterBuilder() + .byHasLineItems(true) + .build(); + } + private TransactionTile makeTile(Transaction transaction) { var tile = new TransactionTile(transaction); tile.setOnMouseClicked(event -> { diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java index 1862043..00cf3ca 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -3,8 +3,10 @@ package com.andrewlalis.perfin.data; import com.andrewlalis.perfin.model.TransactionCategory; import javafx.scene.paint.Color; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; public interface TransactionCategoryRepository extends Repository, AutoCloseable { Optional findById(long id); @@ -17,6 +19,17 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable void update(long id, String name, Color color); void deleteById(long id); - record CategoryTreeNode(TransactionCategory category, List children){} + record CategoryTreeNode(TransactionCategory category, List children) { + public Set allIds() { + Set ids = new HashSet<>(); + ids.add(category.id); + for (var child : children) { + ids.addAll(child.allIds()); + } + return ids; + } + } + List findTree(); + CategoryTreeNode findTree(TransactionCategory root); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java index 91eb30d..b208756 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -119,6 +119,11 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran return rootNodes; } + @Override + public CategoryTreeNode findTree(TransactionCategory root) { + return findTreeRecursive(root); + } + private CategoryTreeNode findTreeRecursive(TransactionCategory root) { CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>()); List childCategories = DbUtil.findAll( diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java index 8f5c111..47b84cf 100644 --- a/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java +++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java @@ -1,14 +1,17 @@ package com.andrewlalis.perfin.data.search; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; import com.andrewlalis.perfin.data.util.DbUtil; -import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.*; import java.math.BigDecimal; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.time.LocalDateTime; -import java.util.Currency; +import java.util.*; +import java.util.stream.Collectors; public class JdbcTransactionSearcher extends JdbcEntitySearcher { public JdbcTransactionSearcher(Connection conn) { @@ -32,4 +35,184 @@ public class JdbcTransactionSearcher extends JdbcEntitySearcher { if (rs.wasNull()) categoryId = null; return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId); } + + public static class FilterBuilder { + private final List filters = new ArrayList<>(); + private final Set joinTables = new HashSet<>(); + + public List build() { + return filters; + } + + public FilterBuilder byAccounts(Collection accounts, boolean exclude) { + if (accounts.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + addAccountEntryJoin(builder); + String idsString = accounts.stream() + .map(a -> Long.toString(a.id)).distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "account_entry.account_id", idsString, exclude); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byAccountTypes(Collection types, boolean exclude) { + if (types.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + addAccountJoin(builder); + String typesString = types.stream() + .map(t -> "'" + t.name() + "'").distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "account.account_type", typesString, exclude); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byCategories(Collection categories, boolean exclude) { + if (categories.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + Set ids = Profile.getCurrent().dataSource().mapRepo(TransactionCategoryRepository.class, repo -> { + Set categoryIds = new HashSet<>(); + for (var category : categories) { + var treeNode = repo.findTree(category); + categoryIds.addAll(treeNode.allIds()); + } + return categoryIds; + }); + String idsString = ids.stream() + .map(id -> Long.toString(id)).distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "transaction.category_id", idsString, exclude); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byVendors(Collection vendors, boolean exclude) { + if (vendors.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + String idsString = vendors.stream() + .map(v -> Long.toString(v.id)).distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "transaction.vendor_id", idsString, exclude); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byTags(Collection tags, boolean exclude) { + if (tags.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + addTagJoin(builder); + var tagIdsString = tags.stream() + .map(t -> Long.toString(t.id)).distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "transaction_tag_join.tag_id", tagIdsString, exclude); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byAmountGreaterThan(BigDecimal amount) { + var builder = new SearchFilter.Builder(); + builder.where("transaction.amount > ?"); + builder.withArg(Types.NUMERIC, amount); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byAmountLessThan(BigDecimal amount) { + var builder = new SearchFilter.Builder(); + builder.where("transaction.amount < ?"); + builder.withArg(Types.NUMERIC, amount); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byAmountEqualTo(BigDecimal amount) { + var builder = new SearchFilter.Builder(); + builder.where("transaction.amount = ?"); + builder.withArg(Types.NUMERIC, amount); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byEntryType(AccountEntry.Type type) { + var builder = new SearchFilter.Builder(); + addAccountEntryJoin(builder); + builder.where("account_entry.type = ?"); + builder.withArg(Types.VARCHAR, type.name()); + filters.add(builder.build()); + return this; + } + + public FilterBuilder byHasAttachments(boolean hasAttachments) { + var builder = new SearchFilter.Builder(); + String subQuery = "(SELECT COUNT(attachment_id) FROM transaction_attachment WHERE transaction_id = transaction.id)"; + if (hasAttachments) { + builder.where(subQuery + " > 0"); + } else { + builder.where(subQuery + " = 0"); + } + filters.add(builder.build()); + return this; + } + + public FilterBuilder byHasLineItems(boolean hasLineItems) { + var builder = new SearchFilter.Builder(); + String subQuery = "(SELECT COUNT(id) FROM transaction_line_item WHERE transaction_id = transaction.id)"; + if (hasLineItems) { + builder.where(subQuery + " > 0"); + } else { + builder.where(subQuery + " = 0"); + } + filters.add(builder.build()); + return this; + } + + public FilterBuilder byCurrencies(Collection currencies, boolean exclude) { + if (currencies.isEmpty()) return this; + var builder = new SearchFilter.Builder(); + String currenciesString = currencies.stream() + .map(c -> "'" + c.getCurrencyCode() + "'").distinct() + .collect(Collectors.joining(",")); + addInClause(builder, "transaction.currency", currenciesString, exclude); + filters.add(builder.build()); + return this; + } + + private void addAccountEntryJoin(SearchFilter.Builder builder) { + if (!joinTables.contains("account_entry")) { + builder.withJoin("LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id"); + joinTables.add("account_entry"); + } + } + + private void addAccountJoin(SearchFilter.Builder builder) { + addAccountEntryJoin(builder); + if (!joinTables.contains("account")) { + builder.withJoin("LEFT JOIN account ON account.id = account_entry.account_id"); + joinTables.add("account"); + } + } + + private void addCategoryJoin(SearchFilter.Builder builder) { + if (!joinTables.contains("transaction_category")) { + builder.withJoin("LEFT JOIN transaction_category ON transaction_category.id = transaction.category_id"); + joinTables.add("transaction_category"); + } + } + + private void addTagJoin(SearchFilter.Builder builder) { + if (!joinTables.contains("transaction_tag_join")) { + builder.withJoin("LEFT JOIN transaction_tag_join ON transaction_tag_join.transaction_id = transaction.id"); + joinTables.add("transaction_tag_join"); + } + } + + private void addInClause(SearchFilter.Builder builder, String valueExpr, String inExpr, boolean exclude) { + if (exclude) { + builder.where(valueExpr + " NOT IN (" + inExpr + ")"); + } else { + builder.where(valueExpr + " IN (" + inExpr + ")"); + } + } + } } diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index 6bbe6c0..f99b98a 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -4,9 +4,7 @@ - - - + + + +