Add Transaction Properties #15
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -3,9 +3,12 @@ package com.andrewlalis.perfin.control;
 | 
			
		|||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.AccountRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Sort;
 | 
			
		||||
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
 | 
			
		||||
import com.andrewlalis.perfin.data.search.SearchFilter;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
			
		||||
import com.andrewlalis.perfin.model.Account;
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
 | 
			
		|||
import javafx.beans.value.ObservableValue;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.Node;
 | 
			
		||||
import javafx.scene.control.TextField;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.HBox;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
| 
						 | 
				
			
			@ -30,8 +34,9 @@ import javafx.stage.FileChooser;
 | 
			
		|||
import java.io.File;
 | 
			
		||||
import java.io.PrintWriter;
 | 
			
		||||
import java.nio.charset.StandardCharsets;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Set;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
    public record RouteContext(Long selectedTransactionId) {}
 | 
			
		||||
 | 
			
		||||
    @FXML public BorderPane transactionsListBorderPane;
 | 
			
		||||
    @FXML public TextField searchField;
 | 
			
		||||
    @FXML public AccountSelectionBox filterByAccountComboBox;
 | 
			
		||||
    @FXML public VBox transactionsVBox;
 | 
			
		||||
    private DataSourcePaginationControls paginationControls;
 | 
			
		||||
| 
						 | 
				
			
			@ -60,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
            paginationControls.setPage(1);
 | 
			
		||||
            selectedTransaction.set(null);
 | 
			
		||||
        });
 | 
			
		||||
        searchField.textProperty().addListener((observable, oldValue, newValue) -> {
 | 
			
		||||
            paginationControls.setPage(1);
 | 
			
		||||
            selectedTransaction.set(null);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.paginationControls = new DataSourcePaginationControls(
 | 
			
		||||
                transactionsVBox.getChildren(),
 | 
			
		||||
                new DataSourcePaginationControls.PageFetcherFunction() {
 | 
			
		||||
                    @Override
 | 
			
		||||
                    public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
 | 
			
		||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
			
		||||
                        try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) {
 | 
			
		||||
                            Page<Transaction> result;
 | 
			
		||||
                            if (accountFilter == null) {
 | 
			
		||||
                                result = repo.findAll(pagination);
 | 
			
		||||
                            } else {
 | 
			
		||||
                                result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
 | 
			
		||||
                            }
 | 
			
		||||
                            return result.map(TransactionsViewController.this::makeTile);
 | 
			
		||||
                        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
			
		||||
                        try (var conn = ds.getConnection()) {
 | 
			
		||||
                            JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
 | 
			
		||||
                            return searcher.search(pagination, getCurrentSearchFilters())
 | 
			
		||||
                                    .map(TransactionsViewController.this::makeTile);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    @Override
 | 
			
		||||
                    public int getTotalCount() throws Exception {
 | 
			
		||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
			
		||||
                        try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) {
 | 
			
		||||
                            if (accountFilter == null) {
 | 
			
		||||
                                return (int) repo.countAll();
 | 
			
		||||
                            } else {
 | 
			
		||||
                                return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
 | 
			
		||||
                            }
 | 
			
		||||
                        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
			
		||||
                        try (var conn = ds.getConnection()) {
 | 
			
		||||
                            JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
 | 
			
		||||
                            return (int) searcher.resultCount(getCurrentSearchFilters());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
| 
						 | 
				
			
			@ -105,9 +108,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
        TransactionViewController transactionViewController = detailComponents.second();
 | 
			
		||||
        BorderPane transactionDetailView = detailComponents.first();
 | 
			
		||||
        detailPanel.getChildren().add(transactionDetailView);
 | 
			
		||||
        selectedTransaction.addListener((observable, oldValue, newValue) -> {
 | 
			
		||||
            transactionViewController.setTransaction(newValue);
 | 
			
		||||
        });
 | 
			
		||||
        selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
 | 
			
		||||
 | 
			
		||||
        // Clear the transactions when a new profile is loaded.
 | 
			
		||||
        Profile.whenLoaded(profile -> {
 | 
			
		||||
| 
						 | 
				
			
			@ -133,16 +134,17 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
 | 
			
		||||
        // If a transaction id is given in the route context, navigate to the page it's on and select it.
 | 
			
		||||
        if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
			
		||||
                repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
 | 
			
		||||
                    long offset = repo.countAllAfter(tx.id);
 | 
			
		||||
                    int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
 | 
			
		||||
                    Platform.runLater(() -> {
 | 
			
		||||
                        paginationControls.setPage(pageNumber);
 | 
			
		||||
                        selectedTransaction.set(tx);
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepoAsync(
 | 
			
		||||
                    TransactionRepository.class,
 | 
			
		||||
                    repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
 | 
			
		||||
                        long offset = repo.countAllAfter(tx.id);
 | 
			
		||||
                        int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
 | 
			
		||||
                        Platform.runLater(() -> {
 | 
			
		||||
                            paginationControls.setPage(pageNumber);
 | 
			
		||||
                            selectedTransaction.set(tx);
 | 
			
		||||
                        });
 | 
			
		||||
                    })
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
            paginationControls.setPage(1);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -180,6 +182,37 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private List<SearchFilter> getCurrentSearchFilters() {
 | 
			
		||||
        List<SearchFilter> filters = new ArrayList<>();
 | 
			
		||||
        if (searchField.getText() != null && !searchField.getText().isBlank()) {
 | 
			
		||||
            var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
 | 
			
		||||
                    .map(t -> '%'+t+'%')
 | 
			
		||||
                    .toList();
 | 
			
		||||
            var builder = new SearchFilter.Builder();
 | 
			
		||||
            List<String> orClauses = new ArrayList<>(likeTerms.size());
 | 
			
		||||
            for (var term : likeTerms) {
 | 
			
		||||
                orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
            }
 | 
			
		||||
            builder.where(String.join(" OR ", orClauses));
 | 
			
		||||
            builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
 | 
			
		||||
            builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
 | 
			
		||||
            filters.add(builder.build());
 | 
			
		||||
        }
 | 
			
		||||
        if (filterByAccountComboBox.getValue() != null) {
 | 
			
		||||
            Account filteredAccount = filterByAccountComboBox.getValue();
 | 
			
		||||
            var filter = new SearchFilter.Builder()
 | 
			
		||||
                    .where("fae.account_id = ?")
 | 
			
		||||
                    .withArg(filteredAccount.id)
 | 
			
		||||
                    .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
 | 
			
		||||
                    .build();
 | 
			
		||||
            filters.add(filter);
 | 
			
		||||
        }
 | 
			
		||||
        return filters;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private TransactionTile makeTile(Transaction transaction) {
 | 
			
		||||
        var tile = new TransactionTile(transaction);
 | 
			
		||||
        tile.setOnMouseClicked(event -> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An entity searcher will search for entities matching a list of filters.
 | 
			
		||||
 * @param <T> The entity type to search over.
 | 
			
		||||
 */
 | 
			
		||||
public interface EntitySearcher<T> {
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets a page of results that match the given filters.
 | 
			
		||||
     * @param pageRequest The page request.
 | 
			
		||||
     * @param filters The filters to apply.
 | 
			
		||||
     * @return A page of results.
 | 
			
		||||
     */
 | 
			
		||||
    Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets the number of results that would be returned for a given set of
 | 
			
		||||
     * filters.
 | 
			
		||||
     * @param filters The filters to apply.
 | 
			
		||||
     * @return The number of entities that match.
 | 
			
		||||
     */
 | 
			
		||||
    long resultCount(List<SearchFilter> filters);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,111 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.ResultSetMapper;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.PreparedStatement;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.stream.Collectors;
 | 
			
		||||
 | 
			
		||||
public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
 | 
			
		||||
    private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
 | 
			
		||||
 | 
			
		||||
    private final Connection conn;
 | 
			
		||||
    private final String countExpression;
 | 
			
		||||
    private final String selectExpression;
 | 
			
		||||
    private final ResultSetMapper<T> resultSetMapper;
 | 
			
		||||
 | 
			
		||||
    public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
 | 
			
		||||
        this.conn = conn;
 | 
			
		||||
        this.countExpression = countExpression;
 | 
			
		||||
        this.selectExpression = selectExpression;
 | 
			
		||||
        this.resultSetMapper = resultSetMapper;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
 | 
			
		||||
        if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
 | 
			
		||||
        StringBuilder sb = new StringBuilder();
 | 
			
		||||
        List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
			
		||||
        for (var filter : filters) {
 | 
			
		||||
            args.addAll(filter.args());
 | 
			
		||||
            for (var joinClause : filter.joinClauses()) {
 | 
			
		||||
                sb.append(joinClause).append('\n');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        sb.append("WHERE\n");
 | 
			
		||||
        for (int i = 0; i < filters.size(); i++) {
 | 
			
		||||
            sb.append(filters.get(i).whereClause());
 | 
			
		||||
            if (i < filters.size() - 1) {
 | 
			
		||||
                sb.append(" AND");
 | 
			
		||||
            }
 | 
			
		||||
            sb.append('\n');
 | 
			
		||||
        }
 | 
			
		||||
        return new Pair<>(sb.toString(), args);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
 | 
			
		||||
        for (int i = 1; i <= args.size(); i++) {
 | 
			
		||||
            Pair<Integer, Object> arg = args.get(i - 1);
 | 
			
		||||
            if (arg.second() == null) {
 | 
			
		||||
                stmt.setNull(i, arg.first());
 | 
			
		||||
            } else {
 | 
			
		||||
                stmt.setObject(i, arg.second(), arg.first());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
 | 
			
		||||
        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
			
		||||
        StringBuilder sqlBuilder = new StringBuilder(selectExpression);
 | 
			
		||||
        if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
 | 
			
		||||
            sqlBuilder.append('\n').append(baseQueryAndArgs.first());
 | 
			
		||||
        }
 | 
			
		||||
        String pagingSql = pageRequest.toSQL();
 | 
			
		||||
        if (pagingSql != null && !pagingSql.isBlank()) {
 | 
			
		||||
            sqlBuilder.append('\n').append(pagingSql);
 | 
			
		||||
        }
 | 
			
		||||
        String sql = sqlBuilder.toString();
 | 
			
		||||
        logger.info("Searching with query:\n{}\nWith arguments: {}", sql, baseQueryAndArgs.second().stream().map(Pair::second).map(Object::toString).collect(Collectors.joining(", ")));
 | 
			
		||||
        try (var stmt = conn.prepareStatement(sql)) {
 | 
			
		||||
            applyArgs(stmt, baseQueryAndArgs.second());
 | 
			
		||||
            ResultSet rs = stmt.executeQuery();
 | 
			
		||||
            List<T> results = new ArrayList<>(pageRequest.size());
 | 
			
		||||
            while (rs.next() && results.size() < pageRequest.size()) {
 | 
			
		||||
                results.add(resultSetMapper.map(rs));
 | 
			
		||||
            }
 | 
			
		||||
            return new Page<>(results, pageRequest);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            logger.error("Search failed.", e);
 | 
			
		||||
            return new Page<>(Collections.emptyList(), pageRequest);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long resultCount(List<SearchFilter> filters) {
 | 
			
		||||
        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
			
		||||
        String sql = countExpression + "\n" + baseQueryAndArgs.first();
 | 
			
		||||
        try (var stmt = conn.prepareStatement(sql)) {
 | 
			
		||||
            applyArgs(stmt, baseQueryAndArgs.second());
 | 
			
		||||
            ResultSet rs = stmt.executeQuery();
 | 
			
		||||
            if (!rs.next()) throw new SQLException("No count result.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            logger.error("Failed to get search result count.", e);
 | 
			
		||||
            return 0L;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static class Builder {
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Transaction;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
 | 
			
		||||
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
 | 
			
		||||
    public JdbcTransactionSearcher(Connection conn) {
 | 
			
		||||
        super(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT COUNT(transaction.id) FROM transaction",
 | 
			
		||||
                "SELECT transaction.* FROM transaction",
 | 
			
		||||
                JdbcTransactionSearcher::parseResultSet
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Transaction parseResultSet(ResultSet rs) throws SQLException {
 | 
			
		||||
        long id = rs.getLong(1);
 | 
			
		||||
        LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
 | 
			
		||||
        BigDecimal amount = rs.getBigDecimal(3);
 | 
			
		||||
        Currency currency = Currency.getInstance(rs.getString(4));
 | 
			
		||||
        String description = rs.getString(5);
 | 
			
		||||
        Long vendorId = rs.getLong(6);
 | 
			
		||||
        if (rs.wasNull()) vendorId = null;
 | 
			
		||||
        Long categoryId = rs.getLong(7);
 | 
			
		||||
        if (rs.wasNull()) categoryId = null;
 | 
			
		||||
        return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
			
		||||
 | 
			
		||||
import java.sql.Types;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public interface SearchFilter {
 | 
			
		||||
    String whereClause();
 | 
			
		||||
    List<Pair<Integer, Object>> args();
 | 
			
		||||
    default List<String> joinClauses() {
 | 
			
		||||
        return Collections.emptyList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
 | 
			
		||||
 | 
			
		||||
    class Builder {
 | 
			
		||||
        private String whereClause;
 | 
			
		||||
        private List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
			
		||||
        private List<String> joinClauses = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
        public Builder where(String clause) {
 | 
			
		||||
            this.whereClause = clause;
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(int sqlType, Object value) {
 | 
			
		||||
            args.add(new Pair<>(sqlType, value));
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(int value) {
 | 
			
		||||
            return withArg(Types.INTEGER, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(long value) {
 | 
			
		||||
            return withArg(Types.BIGINT, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(String value) {
 | 
			
		||||
            return withArg(Types.VARCHAR, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(LocalDateTime utcTimestamp) {
 | 
			
		||||
            return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withJoin(String joinClause) {
 | 
			
		||||
            joinClauses.add(joinClause);
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public SearchFilter build() {
 | 
			
		||||
            return new Impl(whereClause, args, joinClauses);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import javafx.scene.control.TextField?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +21,8 @@
 | 
			
		|||
        <HBox>
 | 
			
		||||
            <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
 | 
			
		||||
                <top>
 | 
			
		||||
                    <HBox styleClass="std-padding,std-spacing">
 | 
			
		||||
                    <HBox styleClass="padding-extra,std-spacing">
 | 
			
		||||
                        <TextField fx:id="searchField" promptText="Search"/>
 | 
			
		||||
                        <PropertiesPane hgap="5" vgap="5">
 | 
			
		||||
                            <Label text="Filter by Account"/>
 | 
			
		||||
                            <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue