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.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) {
|
||||||
|
|
|
@ -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 -> {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
Loading…
Reference in New Issue