Added label for confirming line items total, and for setting amount equal to the line items total.
This commit is contained in:
		
							parent
							
								
									9222b8f990
								
							
						
					
					
						commit
						a9cdc6c41e
					
				| 
						 | 
				
			
			@ -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<TransactionLineItem> 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<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItems);
 | 
			
		||||
        ObservableValue<BigDecimal> lineItemsTotalValue = lineItemsProperty.map(items -> items.stream()
 | 
			
		||||
                .map(TransactionLineItem::getTotalValue)
 | 
			
		||||
                .reduce(BigDecimal.ZERO, BigDecimal::add));
 | 
			
		||||
        ObjectProperty<BigDecimal> 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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -213,6 +213,13 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
        return filters;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Temporary utility to try out the new filter builder.
 | 
			
		||||
    private List<SearchFilter> tmpFilter() {
 | 
			
		||||
        return new JdbcTransactionSearcher.FilterBuilder()
 | 
			
		||||
                .byHasLineItems(true)
 | 
			
		||||
                .build();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private TransactionTile makeTile(Transaction transaction) {
 | 
			
		||||
        var tile = new TransactionTile(transaction);
 | 
			
		||||
        tile.setOnMouseClicked(event -> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<TransactionCategory> 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<CategoryTreeNode> children){}
 | 
			
		||||
    record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children) {
 | 
			
		||||
        public Set<Long> allIds() {
 | 
			
		||||
            Set<Long> ids = new HashSet<>();
 | 
			
		||||
            ids.add(category.id);
 | 
			
		||||
            for (var child : children) {
 | 
			
		||||
                ids.addAll(child.allIds());
 | 
			
		||||
            }
 | 
			
		||||
            return ids;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    List<CategoryTreeNode> findTree();
 | 
			
		||||
    CategoryTreeNode findTree(TransactionCategory root);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<TransactionCategory> childCategories = DbUtil.findAll(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Transaction> {
 | 
			
		||||
    public JdbcTransactionSearcher(Connection conn) {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,4 +35,184 @@ public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
 | 
			
		|||
        if (rs.wasNull()) categoryId = null;
 | 
			
		||||
        return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static class FilterBuilder {
 | 
			
		||||
        private final List<SearchFilter> filters = new ArrayList<>();
 | 
			
		||||
        private final Set<String> joinTables = new HashSet<>();
 | 
			
		||||
 | 
			
		||||
        public List<SearchFilter> build() {
 | 
			
		||||
            return filters;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public FilterBuilder byAccounts(Collection<Account> 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<AccountType> 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<TransactionCategory> categories, boolean exclude) {
 | 
			
		||||
            if (categories.isEmpty()) return this;
 | 
			
		||||
            var builder = new SearchFilter.Builder();
 | 
			
		||||
            Set<Long> ids = Profile.getCurrent().dataSource().mapRepo(TransactionCategoryRepository.class, repo -> {
 | 
			
		||||
                Set<Long> 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<TransactionVendor> 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<TransactionTag> 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<Currency> 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 + ")");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,9 +4,7 @@
 | 
			
		|||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
			
		||||
<?import javafx.scene.control.*?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import javafx.scene.text.Text?>
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
			
		||||
<?import javafx.scene.text.TextFlow?>
 | 
			
		||||
<?import javafx.scene.text.*?>
 | 
			
		||||
<BorderPane
 | 
			
		||||
        xmlns="http://javafx.com/javafx"
 | 
			
		||||
        xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -86,6 +86,7 @@
 | 
			
		|||
                        <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <!-- Container for line items -->
 | 
			
		||||
                <VBox styleClass="std-padding">
 | 
			
		||||
                    <Label text="Line Items" styleClass="bold-text"/>
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +117,13 @@
 | 
			
		|||
                    </VBox>
 | 
			
		||||
 | 
			
		||||
                    <VBox fx:id="lineItemsVBox" styleClass="std-padding, std-spacing"/>
 | 
			
		||||
 | 
			
		||||
                    <Label
 | 
			
		||||
                            fx:id="lineItemsValueMatchLabel"
 | 
			
		||||
                            text="Total value of line items equals the transaction amount."
 | 
			
		||||
                            styleClass="positive-color-text-fill"
 | 
			
		||||
                    />
 | 
			
		||||
                    <Button fx:id="lineItemsAmountSyncButton" text="Set transaction amount to line items total" styleClass="small-font"/>
 | 
			
		||||
                </VBox>
 | 
			
		||||
 | 
			
		||||
                <!-- Container for attachments -->
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue