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){}
|
||||
List<CategoryTreeNode> findTree();
|
||||
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