Added label for confirming line items total, and for setting amount equal to the line items total.

This commit is contained in:
Andrew Lalis 2024-02-20 17:17:21 -05:00
parent 9222b8f990
commit a9cdc6c41e
7 changed files with 260 additions and 6 deletions

View File

@ -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.CurrencyAmountValidator;
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.Bindings;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.fxml.FXML; import javafx.fxml.FXML;
@ -80,6 +82,8 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public Button addLineItemAddButton; @FXML public Button addLineItemAddButton;
@FXML public Button addLineItemCancelButton; @FXML public Button addLineItemCancelButton;
@FXML public VBox lineItemsVBox; @FXML public VBox lineItemsVBox;
@FXML public Label lineItemsValueMatchLabel;
@FXML public Button lineItemsAmountSyncButton;
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false); @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList(); private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
private static long tmpLineItemId = -1L; private static long tmpLineItemId = -1L;
@ -379,6 +383,42 @@ public class EditTransactionController implements RouteSelectionListener {
lineItems.add(tmpItem); lineItems.add(tmpItem);
addingLineItemProperty.set(false); 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) { private Node createLineItemTile(TransactionLineItem item) {

View File

@ -213,6 +213,13 @@ public class TransactionsViewController implements RouteSelectionListener {
return filters; 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) { private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction); var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> { tile.setOnMouseClicked(event -> {

View File

@ -3,8 +3,10 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionCategory; import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
public interface TransactionCategoryRepository extends Repository, AutoCloseable { public interface TransactionCategoryRepository extends Repository, AutoCloseable {
Optional<TransactionCategory> findById(long id); Optional<TransactionCategory> findById(long id);
@ -17,6 +19,17 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable
void update(long id, String name, Color color); void update(long id, String name, Color color);
void deleteById(long id); void deleteById(long id);
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){} record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children) {
List<CategoryTreeNode> findTree(); 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);
} }

View File

@ -119,6 +119,11 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
return rootNodes; return rootNodes;
} }
@Override
public CategoryTreeNode findTree(TransactionCategory root) {
return findTreeRecursive(root);
}
private CategoryTreeNode findTreeRecursive(TransactionCategory root) { private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>()); CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
List<TransactionCategory> childCategories = DbUtil.findAll( List<TransactionCategory> childCategories = DbUtil.findAll(

View File

@ -1,14 +1,17 @@
package com.andrewlalis.perfin.data.search; package com.andrewlalis.perfin.data.search;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Transaction; import com.andrewlalis.perfin.model.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.Connection; import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Types;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Currency; import java.util.*;
import java.util.stream.Collectors;
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> { public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
public JdbcTransactionSearcher(Connection conn) { public JdbcTransactionSearcher(Connection conn) {
@ -32,4 +35,184 @@ public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
if (rs.wasNull()) categoryId = null; if (rs.wasNull()) categoryId = null;
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId); 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 + ")");
}
}
}
} }

View File

@ -4,9 +4,7 @@
<?import com.andrewlalis.perfin.view.component.PropertiesPane?> <?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?> <?import javafx.scene.text.*?>
<?import com.andrewlalis.perfin.view.component.StyledText?>
<?import javafx.scene.text.TextFlow?>
<BorderPane <BorderPane
xmlns="http://javafx.com/javafx" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"

View File

@ -86,6 +86,7 @@
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/> <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
</VBox> </VBox>
</PropertiesPane> </PropertiesPane>
<!-- Container for line items --> <!-- Container for line items -->
<VBox styleClass="std-padding"> <VBox styleClass="std-padding">
<Label text="Line Items" styleClass="bold-text"/> <Label text="Line Items" styleClass="bold-text"/>
@ -116,6 +117,13 @@
</VBox> </VBox>
<VBox fx:id="lineItemsVBox" styleClass="std-padding, std-spacing"/> <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> </VBox>
<!-- Container for attachments --> <!-- Container for attachments -->