Add Transaction Properties #15

Merged
andrewlalis merged 18 commits from transaction-properties into main 2024-02-04 04:31:04 +00:00
16 changed files with 463 additions and 88 deletions
Showing only changes of commit 90ec1e9b09 - Show all commits

View File

@ -10,13 +10,16 @@ import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
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.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property; import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
@ -61,7 +64,7 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public ComboBox<String> vendorComboBox; @FXML public ComboBox<String> vendorComboBox;
@FXML public Hyperlink vendorsHyperlink; @FXML public Hyperlink vendorsHyperlink;
@FXML public ComboBox<String> categoryComboBox; @FXML public CategorySelectionBox categoryComboBox;
@FXML public Hyperlink categoriesHyperlink; @FXML public Hyperlink categoriesHyperlink;
@FXML public ComboBox<String> tagsComboBox; @FXML public ComboBox<String> tagsComboBox;
@FXML public Hyperlink tagsHyperlink; @FXML public Hyperlink tagsHyperlink;
@ -69,6 +72,15 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public VBox tagsVBox; @FXML public VBox tagsVBox;
private final ObservableList<String> selectedTags = FXCollections.observableArrayList(); private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
@FXML public Spinner<Integer> lineItemQuantitySpinner;
@FXML public TextField lineItemValueField;
@FXML public TextField lineItemDescriptionField;
@FXML public Button addLineItemButton;
@FXML public VBox addLineItemForm;
@FXML public Button addLineItemAddButton;
@FXML public Button addLineItemCancelButton;
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
@FXML public FileSelectionArea attachmentsSelectionArea; @FXML public FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton; @FXML public Button saveButton;
@ -97,6 +109,26 @@ public class EditTransactionController implements RouteSelectionListener {
categoriesHyperlink.setOnAction(event -> router.navigate("categories")); categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
tagsHyperlink.setOnAction(event -> router.navigate("tags")); tagsHyperlink.setOnAction(event -> router.navigate("tags"));
// Initialize line item stuff.
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
addLineItemCancelButton.setOnAction(event -> {
lineItemQuantitySpinner.getValueFactory().setValue(1);
lineItemValueField.setText(null);
lineItemDescriptionField.setText(null);
addingLineItemProperty.set(false);
});
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
var lineItemValueValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
).attachToTextField(lineItemDescriptionField);
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
} }
@ -108,7 +140,7 @@ public class EditTransactionController implements RouteSelectionListener {
String description = getSanitizedDescription(); String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
String vendor = vendorComboBox.getValue(); String vendor = vendorComboBox.getValue();
String category = categoryComboBox.getValue(); String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
Set<String> tags = new HashSet<>(selectedTags); Set<String> tags = new HashSet<>(selectedTags);
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
@ -161,7 +193,7 @@ public class EditTransactionController implements RouteSelectionListener {
// Clear some initial fields immediately: // Clear some initial fields immediately:
tagsComboBox.setValue(null); tagsComboBox.setValue(null);
vendorComboBox.setValue(null); vendorComboBox.setValue(null);
categoryComboBox.setValue(null); categoryComboBox.select(null);
if (transaction == null) { if (transaction == null) {
titleLabel.setText("Create New Transaction"); titleLabel.setText("Create New Transaction");
@ -191,17 +223,18 @@ public class EditTransactionController implements RouteSelectionListener {
.toList(); .toList();
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
final List<Attachment> attachments; final List<Attachment> attachments;
final var categoryTreeNodes = categoryRepo.findTree();
final List<String> availableTags = transactionRepo.findAllTags(); final List<String> availableTags = transactionRepo.findAllTags();
final List<String> tags; final List<String> tags;
final CreditAndDebitAccounts linkedAccounts; final CreditAndDebitAccounts linkedAccounts;
final String vendorName; final String vendorName;
final String categoryName; final TransactionCategory category;
if (transaction == null) { if (transaction == null) {
attachments = Collections.emptyList(); attachments = Collections.emptyList();
tags = Collections.emptyList(); tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null); linkedAccounts = new CreditAndDebitAccounts(null, null);
vendorName = null; vendorName = null;
categoryName = null; category = null;
} else { } else {
attachments = transactionRepo.findAttachments(transaction.id); attachments = transactionRepo.findAttachments(transaction.id);
tags = transactionRepo.findTags(transaction.id); tags = transactionRepo.findTags(transaction.id);
@ -213,14 +246,12 @@ public class EditTransactionController implements RouteSelectionListener {
vendorName = null; vendorName = null;
} }
if (transaction.getCategoryId() != null) { if (transaction.getCategoryId() != null) {
categoryName = categoryRepo.findById(transaction.getCategoryId()) category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
.map(TransactionCategory::getName).orElse(null);
} else { } else {
categoryName = null; category = null;
} }
} }
final List<TransactionVendor> availableVendors = vendorRepo.findAll(); final List<TransactionVendor> availableVendors = vendorRepo.findAll();
final List<TransactionCategory> availableCategories = categoryRepo.findAll();
// Then make updates to the view. // Then make updates to the view.
Platform.runLater(() -> { Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies); currencyChoiceBox.getItems().setAll(currencies);
@ -228,12 +259,13 @@ public class EditTransactionController implements RouteSelectionListener {
debitAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts);
vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList()); vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
vendorComboBox.setValue(vendorName); vendorComboBox.setValue(vendorName);
categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList()); categoryComboBox.loadCategories(categoryTreeNodes);
categoryComboBox.setValue(categoryName); categoryComboBox.select(category);
tagsComboBox.getItems().setAll(availableTags); tagsComboBox.getItems().setAll(availableTags);
attachmentsSelectionArea.clear(); attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments); attachmentsSelectionArea.addAttachments(attachments);
selectedTags.clear(); selectedTags.clear();
selectedTags.addAll(tags);
if (transaction == null) { if (transaction == null) {
currencyChoiceBox.getSelectionModel().selectFirst(); currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null); creditAccountSelector.select(null);
@ -242,7 +274,6 @@ public class EditTransactionController implements RouteSelectionListener {
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
creditAccountSelector.select(linkedAccounts.creditAccount()); creditAccountSelector.select(linkedAccounts.creditAccount());
debitAccountSelector.select(linkedAccounts.debitAccount()); debitAccountSelector.select(linkedAccounts.debitAccount());
selectedTags.addAll(tags);
} }
container.setDisable(false); container.setDisable(false);
}); });

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import javafx.application.Platform;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
@ -54,6 +55,10 @@ public class Popups {
error(getWindowFromNode(node), e); error(getWindowFromNode(node), e);
} }
public static void errorLater(Node node, Exception e) {
Platform.runLater(() -> error(node, e));
}
private static Window getWindowFromNode(Node n) { private static Window getWindowFromNode(Node n) {
Window owner = null; Window owner = null;
Scene scene = n.getScene(); Scene scene = n.getScene();

View File

@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts; import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane; import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Hyperlink; import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.shape.Circle;
import javafx.scene.text.TextFlow; import javafx.scene.text.TextFlow;
import org.slf4j.Logger;
import java.util.List; import org.slf4j.LoggerFactory;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController { public class TransactionViewController {
private Transaction transaction; private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
@FXML public Label titleLabel; @FXML public Label titleLabel;
@ -27,47 +41,103 @@ public class TransactionViewController {
@FXML public Label timestampLabel; @FXML public Label timestampLabel;
@FXML public Label descriptionLabel; @FXML public Label descriptionLabel;
@FXML public Label vendorLabel;
@FXML public Circle categoryColorIndicator;
@FXML public Label categoryLabel;
@FXML public Label tagsLabel;
@FXML public Hyperlink debitAccountLink; @FXML public Hyperlink debitAccountLink;
@FXML public Hyperlink creditAccountLink; @FXML public Hyperlink creditAccountLink;
@FXML public AttachmentsViewPane attachmentsViewPane; @FXML public AttachmentsViewPane attachmentsViewPane;
@FXML public void initialize() { @FXML public void initialize() {
configureAccountLinkBindings(debitAccountLink); titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
configureAccountLinkBindings(creditAccountLink); amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
TextFlow debitText = (TextFlow) debitAccountLink.getParent();
BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasDebit()) {
return event -> router.navigate("account", la.debitAccount());
}
return event -> {};
}));
TextFlow creditText = (TextFlow) creditAccountLink.getParent();
BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasCredit()) {
return event -> router.navigate("account", la.creditAccount());
}
return event -> {};
}));
attachmentsViewPane.hideIfEmpty(); attachmentsViewPane.hideIfEmpty();
attachmentsViewPane.listProperty().bindContent(attachmentsList);
transactionProperty.addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
linkedAccountsProperty.set(null);
vendorProperty.set(null);
categoryProperty.set(null);
tagsList.clear();
attachmentsList.clear();
} else {
updateLinkedData(newValue);
}
});
} }
public void setTransaction(Transaction transaction) { public void setTransaction(Transaction transaction) {
this.transaction = transaction; this.transactionProperty.set(transaction);
if (transaction == null) return; }
titleLabel.setText("Transaction #" + transaction.id);
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); private void updateLinkedData(Transaction tx) {
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); var ds = Profile.getCurrent().dataSource();
descriptionLabel.setText(transaction.getDescription()); Thread.ofVirtual().start(() -> {
Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { try (
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); var transactionRepo = ds.getTransactionRepository();
List<Attachment> attachments = repo.findAttachments(transaction.id); var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository()
) {
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
final var attachments = transactionRepo.findAttachments(tx.id);
final var tags = transactionRepo.findTags(tx.id);
Platform.runLater(() -> { Platform.runLater(() -> {
if (accounts.hasDebit()) { linkedAccountsProperty.set(linkedAccounts);
debitAccountLink.setText(accounts.debitAccount().getShortName()); vendorProperty.set(vendor);
debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount())); categoryProperty.set(category);
} else { attachmentsList.setAll(attachments);
debitAccountLink.setText(null); tagsList.setAll(tags);
}
if (accounts.hasCredit()) {
creditAccountLink.setText(accounts.creditAccount().getShortName());
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
} else {
creditAccountLink.setText(null);
}
attachmentsViewPane.setAttachments(attachments);
}); });
} catch (Exception e) {
log.error("Failed to fetch additional transaction data.", e);
Popups.errorLater(titleLabel, e);
}
}); });
} }
@FXML public void editTransaction() { @FXML public void editTransaction() {
router.navigate("edit-transaction", this.transaction); router.navigate("edit-transaction", this.transactionProperty.get());
} }
@FXML public void deleteTransaction() { @FXML public void deleteTransaction() {
@ -82,15 +152,8 @@ public class TransactionViewController {
"it's derived from the most recent balance-record, and transactions." "it's derived from the most recent balance-record, and transactions."
); );
if (confirm) { if (confirm) {
Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id)); Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
router.replace("transactions"); router.replace("transactions");
} }
} }
private void configureAccountLinkBindings(Hyperlink link) {
TextFlow parent = (TextFlow) link.getParent();
parent.managedProperty().bind(parent.visibleProperty());
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
link.setText(null);
}
} }

View File

@ -11,6 +11,7 @@ import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction; import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls; import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
@ -98,14 +99,11 @@ public class TransactionsViewController implements RouteSelectionListener {
detailPanel.minWidthProperty().bind(halfWidthProp); detailPanel.minWidthProperty().bind(halfWidthProp);
detailPanel.maxWidthProperty().bind(halfWidthProp); detailPanel.maxWidthProperty().bind(halfWidthProp);
detailPanel.prefWidthProperty().bind(halfWidthProp); detailPanel.prefWidthProperty().bind(halfWidthProp);
detailPanel.managedProperty().bind(detailPanel.visibleProperty()); BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml"); Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
TransactionViewController transactionViewController = detailComponents.second(); TransactionViewController transactionViewController = detailComponents.second();
BorderPane transactionDetailView = detailComponents.first(); BorderPane transactionDetailView = detailComponents.first();
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
detailPanel.getChildren().add(transactionDetailView); detailPanel.getChildren().add(transactionDetailView);
selectedTransaction.addListener((observable, oldValue, newValue) -> { selectedTransaction.addListener((observable, oldValue, newValue) -> {
transactionViewController.setTransaction(newValue); transactionViewController.setTransaction(newValue);
@ -121,7 +119,7 @@ public class TransactionsViewController implements RouteSelectionListener {
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
paginationControls.sorts.setAll(DEFAULT_SORTS); paginationControls.sorts.setAll(DEFAULT_SORTS);
transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially. selectedTransaction.set(null); // Initially set the selected transaction as null.
// Refresh account filter options. // Refresh account filter options.
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
@ -140,13 +138,13 @@ public class TransactionsViewController implements RouteSelectionListener {
long offset = repo.countAllAfter(tx.id); long offset = repo.countAllAfter(tx.id);
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
Platform.runLater(() -> { Platform.runLater(() -> {
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx)); paginationControls.setPage(pageNumber);
selectedTransaction.set(tx);
}); });
}); });
}); });
} else { } else {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null);
} }
} }

View File

@ -5,8 +5,12 @@ import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migration; import com.andrewlalis.perfin.data.impl.migration.Migration;
import com.andrewlalis.perfin.data.impl.migration.Migrations; import com.andrewlalis.perfin.data.impl.migration.Migrations;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -15,9 +19,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.*;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@ -77,6 +79,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
if (in == null) throw new IOException("Could not load database schema SQL file."); if (in == null) throw new IOException("Could not load database schema SQL file.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
executeSqlScript(schemaStr, conn); executeSqlScript(schemaStr, conn);
insertDefaultData(conn);
try { try {
writeCurrentSchemaVersion(profileName); writeCurrentSchemaVersion(profileName);
} catch (IOException e) { } catch (IOException e) {
@ -97,6 +100,42 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
} }
} }
private void insertDefaultData(Connection conn) throws IOException, SQLException {
try (
var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
var stmt = conn.prepareStatement(
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)
) {
if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
ObjectMapper mapper = new ObjectMapper();
ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
}
}
private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
for (JsonNode obj : categoriesArray) {
String name = obj.get("name").asText();
String colorHex = parentColorHex;
if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
if (parentId == null) {
stmt.setNull(1, Types.BIGINT);
} else {
stmt.setLong(1, parentId);
}
stmt.setString(2, name);
stmt.setString(3, colorHex.substring(1));
int result = stmt.executeUpdate();
if (result != 1) throw new SQLException("Failed to insert category.");
long id = DbUtil.getGeneratedId(stmt);
if (obj.hasNonNull("children") && obj.get("children").isArray()) {
insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
}
}
}
private boolean testConnection(JdbcDataSource dataSource) { private boolean testConnection(JdbcDataSource dataSource) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
return stmt.execute("SELECT 1;"); return stmt.execute("SELECT 1;");

View File

@ -31,6 +31,15 @@ public final class DbUtil {
setArgs(stmt, List.of(args)); setArgs(stmt, List.of(args));
} }
public static long getGeneratedId(PreparedStatement stmt) {
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (!rs.next()) throw new SQLException("No generated keys available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) { public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); setArgs(stmt, args);
@ -116,9 +125,7 @@ public final class DbUtil {
setArgs(stmt, args); setArgs(stmt, args);
int result = stmt.executeUpdate(); int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
var rs = stmt.getGeneratedKeys(); return getGeneratedId(stmt);
if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
return rs.getLong(1);
} catch (SQLException e) { } catch (SQLException e) {
throw new UncheckedSqlException(e); throw new UncheckedSqlException(e);
} }
@ -155,7 +162,9 @@ public final class DbUtil {
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) { public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
try { try {
conn.setAutoCommit(false); conn.setAutoCommit(false);
return supplier.offer(); T result = supplier.offer();
conn.commit();
return result;
} catch (Exception e) { } catch (Exception e) {
try { try {
conn.rollback(); conn.rollback();

View File

@ -1,8 +1,10 @@
package com.andrewlalis.perfin.view; package com.andrewlalis.perfin.view;
import javafx.beans.WeakListener; import javafx.beans.WeakListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.scene.Node;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.util.List; import java.util.List;
@ -86,4 +88,9 @@ public class BindingUtil {
return false; return false;
} }
} }
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
node.managedProperty().bind(node.visibleProperty());
node.visibleProperty().bind(value);
}
} }

View File

@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
showBalanceProperty.set(value); showBalanceProperty.set(value);
} }
private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> { /**
private final BooleanProperty showBalanceProp; * A simple cell factory that just returns instances of {@link AccountListCell}.
* @param showBalanceProp Whether to show the account's balance.
private CellFactory(BooleanProperty showBalanceProp) { */
this.showBalanceProp = showBalanceProp; private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
}
@Override @Override
public ListCell<Account> call(ListView<Account> param) { public ListCell<Account> call(ListView<Account> param) {
return new AccountListCell(showBalanceProp); return new AccountListCell(showBalanceProp);
} }
} }
/**
* A list cell implementation which shows an account's name, and optionally,
* its current derived balance underneath.
*/
private static class AccountListCell extends ListCell<Account> { private static class AccountListCell extends ListCell<Account> {
private final BooleanProperty showBalanceProp; private final BooleanProperty showBalanceProp;
private final Label nameLabel = new Label(); private final Label nameLabel = new Label();

View File

@ -0,0 +1,81 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.geometry.Insets;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.layout.HBox;
import javafx.scene.shape.Circle;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CategorySelectionBox extends ComboBox<TransactionCategory> {
private final Map<TransactionCategory, Integer> categoryIndentationLevels = new HashMap<>();
public CategorySelectionBox() {
setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
setButtonCell(new CategoryListCell(null));
}
public void loadCategories(List<TransactionCategoryRepository.CategoryTreeNode> treeNodes) {
categoryIndentationLevels.clear();
getItems().clear();
populateCategories(treeNodes, 0);
getItems().add(null);
}
private void populateCategories(
List<TransactionCategoryRepository.CategoryTreeNode> treeNodes,
int depth
) {
for (var node : treeNodes) {
getItems().add(node.category());
categoryIndentationLevels.put(node.category(), depth);
populateCategories(node.children(), depth + 1);
}
}
public void select(TransactionCategory category) {
setButtonCell(new CategoryListCell(null));
getSelectionModel().select(category);
}
private static class CategoryListCell extends ListCell<TransactionCategory> {
private final Label nameLabel = new Label();
private final Circle colorIndicator = new Circle(8);
private final Map<TransactionCategory, Integer> categoryIndentationLevels;
public CategoryListCell(Map<TransactionCategory, Integer> categoryIndentationLevels) {
this.categoryIndentationLevels = categoryIndentationLevels;
nameLabel.getStyleClass().add("normal-color-text-fill");
colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
HBox container = new HBox(colorIndicator, nameLabel);
container.getStyleClass().add("std-spacing");
setGraphic(container);
}
@Override
protected void updateItem(TransactionCategory item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
nameLabel.setText("None");
colorIndicator.setVisible(false);
return;
}
nameLabel.setText(item.getName());
if (categoryIndentationLevels != null) {
HBox.setMargin(
colorIndicator,
new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
);
}
colorIndicator.setVisible(true);
colorIndicator.setFill(item.getColor());
}
}
}

View File

@ -55,7 +55,7 @@
</PropertiesPane> </PropertiesPane>
<Separator/> <Separator/>
<HBox styleClass="std-padding,std-spacing"> <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>

View File

@ -24,7 +24,8 @@
<Label text="Color" labelFor="${colorPicker}"/> <Label text="Color" labelFor="${colorPicker}"/>
<ColorPicker fx:id="colorPicker"/> <ColorPicker fx:id="colorPicker"/>
</PropertiesPane> </PropertiesPane>
<HBox styleClass="std-padding, std-spacing"> <Separator/>
<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>

View File

@ -5,6 +5,8 @@
<?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 com.andrewlalis.perfin.view.component.CategorySelectionBox?>
<?import com.andrewlalis.perfin.view.component.StyledText?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.EditTransactionController" fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
@ -70,7 +72,7 @@
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/> <Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
<Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/> <Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
</VBox> </VBox>
<ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/> <CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
<VBox> <VBox>
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/> <Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
@ -83,6 +85,36 @@
</HBox> </HBox>
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/> <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
</VBox> </VBox>
<Label text="Line Items" styleClass="bold-text"/>
<VBox maxWidth="Infinity">
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
<StyledText styleClass="small-font">
Line items aren't yet supported. I'm working on it!
</StyledText>
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
<HBox styleClass="std-spacing">
<VBox>
<Label text="Quantity" styleClass="bold-text,small-font"/>
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
</VBox>
<VBox HBox.hgrow="ALWAYS">
<Label text="Value per Item" styleClass="bold-text,small-font"/>
<TextField fx:id="lineItemValueField"/>
</VBox>
</HBox>
<VBox>
<Label text="Description" styleClass="bold-text,small-font"/>
<TextField fx:id="lineItemDescriptionField"/>
</VBox>
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
<Button text="Add" fx:id="addLineItemAddButton"/>
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
</HBox>
</VBox>
<VBox fx:id="lineItemsVBox"/>
</VBox>
</PropertiesPane> </PropertiesPane>
<!-- Container for attachments --> <!-- Container for attachments -->

View File

@ -24,7 +24,8 @@
<Label text="Description" labelFor="${descriptionField}"/> <Label text="Description" labelFor="${descriptionField}"/>
<TextArea fx:id="descriptionField" wrapText="true"/> <TextArea fx:id="descriptionField" wrapText="true"/>
</PropertiesPane> </PropertiesPane>
<HBox styleClass="std-padding,std-spacing"> <Separator/>
<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>

View File

@ -0,0 +1,82 @@
[
{
"name": "Food & Drink",
"color": "#10C600",
"children": [
{"name": "Groceries"},
{"name": "Restaurants"},
{"name": "Alcohol & Bars"}
]
},
{
"name": "Transportation",
"color": "#4688FF",
"children": [
{"name": "Car & Fuel"},
{"name": "Public Transport"},
{"name": "Air Travel"},
{"name": "Taxi & Rideshare"}
]
},
{
"name": "Household",
"color": "#E5DF00",
"children": [
{"name": "Rent & Mortgage"},
{"name": "Utilities"},
{"name": "Insurance & Fees"},
{"name": "Home Improvements & Renovation"},
{"name": "Household Supplies"},
{"name": "Pets"},
{"name": "Garden"}
]
},
{
"name": "Shopping",
"color": "#BF2484",
"children": [
{"name": "Clothes & Accessories"},
{"name": "Electronics"},
{"name": "Hobbies & Crafts"},
{"name": "Gifts"}
]
},
{
"name": "Leisure",
"color": "#C7271C",
"children": [
{"name": "Culture & Events"},
{"name": "Movies & Media"},
{"name": "Vacation"}
]
},
{
"name": "Health & Wellness",
"color": "#BE11D7",
"children": [
{"name": "Healthcare"},
{"name": "Medication"},
{"name": "Vision"},
{"name": "Dental"},
{"name": "Fitness & Sports"}
]
},
{
"name": "Income",
"color": "#83F25C",
"children": [
{"name": "Cash Deposits"},
{"name": "Salary"},
{"name": "Pension"},
{"name": "Investments"}
]
},
{
"name": "Transfers",
"color": "#F7AE39"
},
{
"name": "Charity",
"color": "#9AE5FF"
}
]

View File

@ -173,11 +173,3 @@ CREATE TABLE account_history_item_balance_record (
FOREIGN KEY (record_id) REFERENCES balance_record(id) FOREIGN KEY (record_id) REFERENCES balance_record(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
/* DEFAULT ENTITIES */
INSERT INTO transaction_category (name, color) VALUES
('Food', '0dba0d'),
('Travel', '3ec0f0'),
('Utilities', '4137bf'),
('Housing', 'e6cf3c'),
('Entertainment', 'e01e1b');

View File

@ -6,6 +6,7 @@
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?> <?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?> <?import javafx.scene.text.TextFlow?>
<?import javafx.scene.shape.Circle?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionViewController" fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
@ -32,6 +33,37 @@
<Label text="Description" styleClass="bold-text"/> <Label text="Description" styleClass="bold-text"/>
<Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/> <Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
</PropertiesPane> </PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Vendor" styleClass="bold-text"/>
<Label fx:id="vendorLabel"/>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Category" styleClass="bold-text"/>
<HBox styleClass="std-spacing">
<Circle radius="8" fx:id="categoryColorIndicator"/>
<Label fx:id="categoryLabel"/>
</HBox>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Tags" styleClass="bold-text"/>
<Label fx:id="tagsLabel"/>
</PropertiesPane>
<VBox> <VBox>
<TextFlow> <TextFlow>
<Text text="Debited to"/> <Text text="Debited to"/>
@ -45,7 +77,7 @@
<AttachmentsViewPane fx:id="attachmentsViewPane"/> <AttachmentsViewPane fx:id="attachmentsViewPane"/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT"> <HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
<Button text="Edit" onAction="#editTransaction"/> <Button text="Edit" onAction="#editTransaction"/>
<Button text="Delete this transaction" onAction="#deleteTransaction"/> <Button text="Delete" onAction="#deleteTransaction"/>
</HBox> </HBox>
</VBox> </VBox>
</ScrollPane> </ScrollPane>