Add Transaction Properties #15
|
@ -10,13 +10,16 @@ import com.andrewlalis.perfin.data.util.DateUtil;
|
|||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
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.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
|
@ -61,7 +64,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
|
||||
@FXML public ComboBox<String> vendorComboBox;
|
||||
@FXML public Hyperlink vendorsHyperlink;
|
||||
@FXML public ComboBox<String> categoryComboBox;
|
||||
@FXML public CategorySelectionBox categoryComboBox;
|
||||
@FXML public Hyperlink categoriesHyperlink;
|
||||
@FXML public ComboBox<String> tagsComboBox;
|
||||
@FXML public Hyperlink tagsHyperlink;
|
||||
|
@ -69,6 +72,15 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@FXML public VBox tagsVBox;
|
||||
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 Button saveButton;
|
||||
|
@ -97,6 +109,26 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||
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);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
}
|
||||
|
@ -108,7 +140,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
String description = getSanitizedDescription();
|
||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||
String vendor = vendorComboBox.getValue();
|
||||
String category = categoryComboBox.getValue();
|
||||
String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
|
||||
Set<String> tags = new HashSet<>(selectedTags);
|
||||
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
||||
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
||||
|
@ -161,7 +193,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
// Clear some initial fields immediately:
|
||||
tagsComboBox.setValue(null);
|
||||
vendorComboBox.setValue(null);
|
||||
categoryComboBox.setValue(null);
|
||||
categoryComboBox.select(null);
|
||||
|
||||
if (transaction == null) {
|
||||
titleLabel.setText("Create New Transaction");
|
||||
|
@ -191,17 +223,18 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
.toList();
|
||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
final List<Attachment> attachments;
|
||||
final var categoryTreeNodes = categoryRepo.findTree();
|
||||
final List<String> availableTags = transactionRepo.findAllTags();
|
||||
final List<String> tags;
|
||||
final CreditAndDebitAccounts linkedAccounts;
|
||||
final String vendorName;
|
||||
final String categoryName;
|
||||
final TransactionCategory category;
|
||||
if (transaction == null) {
|
||||
attachments = Collections.emptyList();
|
||||
tags = Collections.emptyList();
|
||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||
vendorName = null;
|
||||
categoryName = null;
|
||||
category = null;
|
||||
} else {
|
||||
attachments = transactionRepo.findAttachments(transaction.id);
|
||||
tags = transactionRepo.findTags(transaction.id);
|
||||
|
@ -213,14 +246,12 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
vendorName = null;
|
||||
}
|
||||
if (transaction.getCategoryId() != null) {
|
||||
categoryName = categoryRepo.findById(transaction.getCategoryId())
|
||||
.map(TransactionCategory::getName).orElse(null);
|
||||
category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
|
||||
} else {
|
||||
categoryName = null;
|
||||
category = null;
|
||||
}
|
||||
}
|
||||
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||
final List<TransactionCategory> availableCategories = categoryRepo.findAll();
|
||||
// Then make updates to the view.
|
||||
Platform.runLater(() -> {
|
||||
currencyChoiceBox.getItems().setAll(currencies);
|
||||
|
@ -228,12 +259,13 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
debitAccountSelector.setAccounts(accounts);
|
||||
vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
|
||||
vendorComboBox.setValue(vendorName);
|
||||
categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList());
|
||||
categoryComboBox.setValue(categoryName);
|
||||
categoryComboBox.loadCategories(categoryTreeNodes);
|
||||
categoryComboBox.select(category);
|
||||
tagsComboBox.getItems().setAll(availableTags);
|
||||
attachmentsSelectionArea.clear();
|
||||
attachmentsSelectionArea.addAttachments(attachments);
|
||||
selectedTags.clear();
|
||||
selectedTags.addAll(tags);
|
||||
if (transaction == null) {
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
creditAccountSelector.select(null);
|
||||
|
@ -242,7 +274,6 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
|
||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||
selectedTags.addAll(tags);
|
||||
}
|
||||
container.setDisable(false);
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
|
@ -54,6 +55,10 @@ public class Popups {
|
|||
error(getWindowFromNode(node), e);
|
||||
}
|
||||
|
||||
public static void errorLater(Node node, Exception e) {
|
||||
Platform.runLater(() -> error(node, e));
|
||||
}
|
||||
|
||||
private static Window getWindowFromNode(Node n) {
|
||||
Window owner = null;
|
||||
Scene scene = n.getScene();
|
||||
|
|
|
@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
|
|||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
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.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
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;
|
||||
|
||||
|
@ -27,47 +41,103 @@ public class TransactionViewController {
|
|||
@FXML public Label timestampLabel;
|
||||
@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 creditAccountLink;
|
||||
|
||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||
|
||||
@FXML public void initialize() {
|
||||
configureAccountLinkBindings(debitAccountLink);
|
||||
configureAccountLinkBindings(creditAccountLink);
|
||||
titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
|
||||
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.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) {
|
||||
this.transaction = transaction;
|
||||
if (transaction == null) return;
|
||||
titleLabel.setText("Transaction #" + transaction.id);
|
||||
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
|
||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
|
||||
descriptionLabel.setText(transaction.getDescription());
|
||||
Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
||||
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
|
||||
List<Attachment> attachments = repo.findAttachments(transaction.id);
|
||||
this.transactionProperty.set(transaction);
|
||||
}
|
||||
|
||||
private void updateLinkedData(Transaction tx) {
|
||||
var ds = Profile.getCurrent().dataSource();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (
|
||||
var transactionRepo = ds.getTransactionRepository();
|
||||
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(() -> {
|
||||
if (accounts.hasDebit()) {
|
||||
debitAccountLink.setText(accounts.debitAccount().getShortName());
|
||||
debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
|
||||
} else {
|
||||
debitAccountLink.setText(null);
|
||||
}
|
||||
if (accounts.hasCredit()) {
|
||||
creditAccountLink.setText(accounts.creditAccount().getShortName());
|
||||
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
|
||||
} else {
|
||||
creditAccountLink.setText(null);
|
||||
}
|
||||
attachmentsViewPane.setAttachments(attachments);
|
||||
linkedAccountsProperty.set(linkedAccounts);
|
||||
vendorProperty.set(vendor);
|
||||
categoryProperty.set(category);
|
||||
attachmentsList.setAll(attachments);
|
||||
tagsList.setAll(tags);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch additional transaction data.", e);
|
||||
Popups.errorLater(titleLabel, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML public void editTransaction() {
|
||||
router.navigate("edit-transaction", this.transaction);
|
||||
router.navigate("edit-transaction", this.transactionProperty.get());
|
||||
}
|
||||
|
||||
@FXML public void deleteTransaction() {
|
||||
|
@ -82,15 +152,8 @@ public class TransactionViewController {
|
|||
"it's derived from the most recent balance-record, and transactions."
|
||||
);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
private void configureAccountLinkBindings(Hyperlink link) {
|
||||
TextFlow parent = (TextFlow) link.getParent();
|
||||
parent.managedProperty().bind(parent.visibleProperty());
|
||||
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
|
||||
link.setText(null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.andrewlalis.perfin.data.util.Pair;
|
|||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.SceneUtil;
|
||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
|
||||
|
@ -98,14 +99,11 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
detailPanel.minWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.maxWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.prefWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.managedProperty().bind(detailPanel.visibleProperty());
|
||||
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
|
||||
BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
|
||||
|
||||
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
|
||||
TransactionViewController transactionViewController = detailComponents.second();
|
||||
BorderPane transactionDetailView = detailComponents.first();
|
||||
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
|
||||
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
|
||||
detailPanel.getChildren().add(transactionDetailView);
|
||||
selectedTransaction.addListener((observable, oldValue, newValue) -> {
|
||||
transactionViewController.setTransaction(newValue);
|
||||
|
@ -121,7 +119,7 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
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.
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
|
@ -140,13 +138,13 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
long offset = repo.countAllAfter(tx.id);
|
||||
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
|
||||
Platform.runLater(() -> {
|
||||
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
|
||||
paginationControls.setPage(pageNumber);
|
||||
selectedTransaction.set(tx);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,8 +5,12 @@ import com.andrewlalis.perfin.data.DataSourceFactory;
|
|||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.impl.migration.Migration;
|
||||
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.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.LoggerFactory;
|
||||
|
||||
|
@ -15,9 +19,7 @@ import java.io.InputStream;
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.*;
|
||||
import java.util.Arrays;
|
||||
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.");
|
||||
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
executeSqlScript(schemaStr, conn);
|
||||
insertDefaultData(conn);
|
||||
try {
|
||||
writeCurrentSchemaVersion(profileName);
|
||||
} 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) {
|
||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||
return stmt.execute("SELECT 1;");
|
||||
|
|
|
@ -31,6 +31,15 @@ public final class DbUtil {
|
|||
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) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
|
@ -116,9 +125,7 @@ public final class DbUtil {
|
|||
setArgs(stmt, args);
|
||||
int result = stmt.executeUpdate();
|
||||
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
||||
var rs = stmt.getGeneratedKeys();
|
||||
if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
|
||||
return rs.getLong(1);
|
||||
return getGeneratedId(stmt);
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
|
@ -155,7 +162,9 @@ public final class DbUtil {
|
|||
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
|
||||
try {
|
||||
conn.setAutoCommit(false);
|
||||
return supplier.offer();
|
||||
T result = supplier.offer();
|
||||
conn.commit();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
conn.rollback();
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package com.andrewlalis.perfin.view;
|
||||
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
|
@ -86,4 +88,9 @@ public class BindingUtil {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
|
||||
node.managedProperty().bind(node.visibleProperty());
|
||||
node.visibleProperty().bind(value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
|||
showBalanceProperty.set(value);
|
||||
}
|
||||
|
||||
private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> {
|
||||
private final BooleanProperty showBalanceProp;
|
||||
|
||||
private CellFactory(BooleanProperty showBalanceProp) {
|
||||
this.showBalanceProp = showBalanceProp;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple cell factory that just returns instances of {@link AccountListCell}.
|
||||
* @param showBalanceProp Whether to show the account's balance.
|
||||
*/
|
||||
private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
|
||||
@Override
|
||||
public ListCell<Account> call(ListView<Account> param) {
|
||||
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 final BooleanProperty showBalanceProp;
|
||||
private final Label nameLabel = new Label();
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -55,7 +55,7 @@
|
|||
</PropertiesPane>
|
||||
|
||||
<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="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
|
|
|
@ -24,7 +24,8 @@
|
|||
<Label text="Color" labelFor="${colorPicker}"/>
|
||||
<ColorPicker fx:id="colorPicker"/>
|
||||
</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="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
||||
|
@ -70,7 +72,7 @@
|
|||
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
|
||||
<Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
|
||||
</VBox>
|
||||
<ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/>
|
||||
<CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
|
||||
|
||||
<VBox>
|
||||
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
|
||||
|
@ -83,6 +85,36 @@
|
|||
</HBox>
|
||||
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
|
||||
</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>
|
||||
|
||||
<!-- Container for attachments -->
|
||||
|
|
|
@ -24,7 +24,8 @@
|
|||
<Label text="Description" labelFor="${descriptionField}"/>
|
||||
<TextArea fx:id="descriptionField" wrapText="true"/>
|
||||
</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="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -173,11 +173,3 @@ CREATE TABLE account_history_item_balance_record (
|
|||
FOREIGN KEY (record_id) REFERENCES balance_record(id)
|
||||
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');
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import javafx.scene.text.TextFlow?>
|
||||
<?import javafx.scene.shape.Circle?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
|
||||
|
@ -32,6 +33,37 @@
|
|||
<Label text="Description" styleClass="bold-text"/>
|
||||
<Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
|
||||
</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>
|
||||
<TextFlow>
|
||||
<Text text="Debited to"/>
|
||||
|
@ -45,7 +77,7 @@
|
|||
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
|
||||
<Button text="Edit" onAction="#editTransaction"/>
|
||||
<Button text="Delete this transaction" onAction="#deleteTransaction"/>
|
||||
<Button text="Delete" onAction="#deleteTransaction"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
|
|
Loading…
Reference in New Issue