Compare commits

..

3 Commits

6 changed files with 129 additions and 35 deletions

View File

@ -91,23 +91,19 @@ public class AccountViewController implements RouteSelectionListener {
Popups.message(balanceCheckerButton, msg); Popups.message(balanceCheckerButton, msg);
})); }));
}); });
accountProperty.addListener((observable, oldValue, newValue) -> {
accountHistory.clear();
if (newValue == null) {
balanceTextProperty.set(null);
} else {
accountHistory.setAccountId(newValue.id);
accountHistory.loadMoreHistory();
Profile.getCurrent().dataSource().getAccountBalanceText(newValue)
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
}
});
} }
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
this.accountProperty.set((Account) context); accountHistory.clear();
balanceTextProperty.set(null);
if (context instanceof Account account) {
this.accountProperty.set(account);
accountHistory.setAccountId(account.id);
accountHistory.loadMoreHistory();
Profile.getCurrent().dataSource().getAccountBalanceText(account)
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
}
} }
@FXML @FXML

View File

@ -1,19 +1,24 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.TransactionVendorRepository; import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionVendor; import com.andrewlalis.perfin.model.TransactionVendor;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea; import javafx.scene.control.TextArea;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -24,6 +29,8 @@ public class EditVendorController implements RouteSelectionListener {
@FXML public TextArea descriptionField; @FXML public TextArea descriptionField;
@FXML public Button saveButton; @FXML public Button saveButton;
@FXML public Label totalSpentField;
@FXML public void initialize() { @FXML public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>() var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.") .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
@ -63,9 +70,19 @@ public class EditVendorController implements RouteSelectionListener {
this.vendor = tv; this.vendor = tv;
nameField.setText(vendor.getName()); nameField.setText(vendor.getName());
descriptionField.setText(vendor.getDescription()); descriptionField.setText(vendor.getDescription());
Profile.getCurrent().dataSource().mapRepoAsync(
AnalyticsRepository.class,
repo -> repo.getVendorSpend(TimestampRange.unbounded(), vendor.id)
).thenAccept(amounts -> {
String text = amounts.stream()
.map(CurrencyUtil::formatMoney)
.collect(Collectors.joining(", "));
Platform.runLater(() -> totalSpentField.setText(text.isBlank() ? "None" : text));
});
} else { } else {
nameField.setText(null); nameField.setText(null);
descriptionField.setText(null); descriptionField.setText(null);
totalSpentField.setText(null);
} }
} }

View File

@ -47,7 +47,11 @@ import static com.andrewlalis.perfin.PerfinApp.router;
* to a specific page. * to a specific page.
*/ */
public class TransactionsViewController implements RouteSelectionListener { public class TransactionsViewController implements RouteSelectionListener {
public static List<Sort> DEFAULT_SORTS = List.of(Sort.desc("timestamp")); public static List<Sort> DEFAULT_SORTS = List.of(
Sort.desc("timestamp"),
Sort.desc("amount"),
Sort.desc("currency")
);
public record RouteContext(Long selectedTransactionId) {} public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane; @FXML public BorderPane transactionsListBorderPane;
@ -188,15 +192,28 @@ public class TransactionsViewController implements RouteSelectionListener {
private List<SearchFilter> getCurrentSearchFilters() { private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> filters = new ArrayList<>(); List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) { if (searchField.getText() != null && !searchField.getText().isBlank()) {
final String text = searchField.getText().strip();
// Special case: for input like "#123", search directly for the transaction id. // Special case: for input like "#123", search directly for the transaction id.
if (searchField.getText().strip().matches("#\\d+")) { if (text.matches("#\\d+")) {
int idQuery = Integer.parseInt(searchField.getText().strip().substring(1)); int idQuery = Integer.parseInt(text.substring(1));
var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build(); var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build();
return List.of(filter); return List.of(filter);
} }
// Special case: for input like "tag: abc", search directly for transactions with tags like that.
if (text.matches("tag:\\s*.+")) {
String tagQuery = "%" + text.substring(4).strip().toLowerCase() + "%";
var filter = new SearchFilter.Builder().where("""
id IN (
SELECT ttj.transaction_id
FROM transaction_tag_join ttj
LEFT JOIN transaction_tag tt ON tt.id = ttj.tag_id
WHERE LOWER(tt.name) LIKE ?
)""").withArg(tagQuery).build();
return List.of(filter);
}
// General case: split the input into a list of terms, then apply each term in a LIKE %term% query. // General case: split the input into a list of terms, then apply each term in a LIKE %term% query.
var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+")) var likeTerms = Arrays.stream(text.toLowerCase().split("\\s+"))
.map(t -> '%'+t+'%') .map(t -> '%'+t+'%')
.toList(); .toList();
var builder = new SearchFilter.Builder(); var builder = new SearchFilter.Builder();

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.data; package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.TransactionCategory; import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor; import com.andrewlalis.perfin.model.TransactionVendor;
@ -14,4 +15,13 @@ public interface AnalyticsRepository extends Repository, AutoCloseable {
List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency); List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency); List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency); List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency);
/**
* Gets the amount spent, grouped by currency, on a specific vendor.
* @param range The time range to search in.
* @param vendorId The id of the vendor to search with.
* @return A list of money values with the total amount spent in each
* currency. An empty list is returned if no money is spent.
*/
List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId);
} }

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.TransactionCategory; import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor; import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
@ -72,6 +73,38 @@ public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepos
); );
} }
@Override
public List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
transaction.currency AS currency,
FROM transaction
WHERE
transaction.vendor_id = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = transaction.id
) AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id) = 1 AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id AND ae.type = 'CREDIT') = 1
GROUP BY transaction.currency
ORDER BY total DESC""",
List.of(vendorId, range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
String currencyCode = rs.getString(2);
return new MoneyValue(total, Currency.getInstance(currencyCode));
}
);
}
@Override @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();

View File

@ -11,24 +11,45 @@
<Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/> <Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
</top> </top>
<center> <center>
<VBox> <ScrollPane fitToWidth="true" fitToHeight="true">
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500"> <VBox style="-fx-max-width: 500px;">
<!-- Basic properties -->
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints> <columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/> <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/> <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints> </columnConstraints>
<Label text="Name" labelFor="${nameField}"/> <Label text="Name" labelFor="${nameField}" styleClass="bold-text"/>
<TextField fx:id="nameField"/> <TextField fx:id="nameField"/>
<Label text="Description" labelFor="${descriptionField}"/> <Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
<TextArea fx:id="descriptionField" wrapText="true"/> <TextArea
fx:id="descriptionField"
wrapText="true"
style="-fx-pref-height: 100px; -fx-min-height: 100px;"
/>
</PropertiesPane> </PropertiesPane>
<!-- Some stats about the vendor -->
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<Label text="Total Spent" labelFor="${totalSpentField}" styleClass="bold-text"/>
<Label fx:id="totalSpentField" styleClass="mono-font"/>
</PropertiesPane>
<!-- Buttons -->
<Separator/> <Separator/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT"> <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
<Button text="Save" fx:id="saveButton" onAction="#save"/> <Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/> <Button text="Cancel" onAction="#cancel"/>
</HBox> </HBox>
</VBox> </VBox>
</ScrollPane>
</center> </center>
</BorderPane> </BorderPane>