Added the ability to add, edit, and remove transaction line items.
This commit is contained in:
parent
5cc789419c
commit
41530d5276
|
@ -1,6 +1,7 @@
|
||||||
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.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.component.module.*;
|
import com.andrewlalis.perfin.view.component.module.*;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.geometry.Bounds;
|
import javafx.geometry.Bounds;
|
||||||
|
@ -34,9 +35,11 @@ public class DashboardController implements RouteSelectionListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
for (var child : modulesFlowPane.getChildren()) {
|
Profile.whenLoaded(profile -> {
|
||||||
DashboardModule module = (DashboardModule) child;
|
for (var child : modulesFlowPane.getChildren()) {
|
||||||
module.refreshContents();
|
DashboardModule module = (DashboardModule) child;
|
||||||
}
|
module.refreshContents();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,15 +12,14 @@ 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.CategorySelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
|
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.*;
|
||||||
import javafx.beans.property.Property;
|
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
@ -75,11 +74,15 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
||||||
@FXML public TextField lineItemValueField;
|
@FXML public TextField lineItemValueField;
|
||||||
@FXML public TextField lineItemDescriptionField;
|
@FXML public TextField lineItemDescriptionField;
|
||||||
|
@FXML public CategorySelectionBox lineItemCategoryComboBox;
|
||||||
@FXML public Button addLineItemButton;
|
@FXML public Button addLineItemButton;
|
||||||
@FXML public VBox addLineItemForm;
|
@FXML public VBox addLineItemForm;
|
||||||
@FXML public Button addLineItemAddButton;
|
@FXML public Button addLineItemAddButton;
|
||||||
@FXML public Button addLineItemCancelButton;
|
@FXML public Button addLineItemCancelButton;
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
|
||||||
|
private static long tmpLineItemId = -1L;
|
||||||
|
|
||||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||||
|
|
||||||
|
@ -96,39 +99,40 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
return ts != null && ts.isBefore(LocalDateTime.now());
|
return ts != null && ts.isBefore(LocalDateTime.now());
|
||||||
}, "Timestamp cannot be in the future.")
|
}, "Timestamp cannot be in the future.")
|
||||||
).validatedInitially().attachToTextField(timestampField);
|
).validatedInitially().attachToTextField(timestampField);
|
||||||
var amountValid = new ValidationApplier<>(
|
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
|
||||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
@Override
|
||||||
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
|
public ValidationResult validate(String input) {
|
||||||
|
var r = super.validate(input);
|
||||||
|
if (!r.isValid()) return r;
|
||||||
|
// Check that this amount is enough to cover the total of any line items.
|
||||||
|
BigDecimal lineItemsTotal = lineItems.stream().map(TransactionLineItem::getTotalValue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal transactionAmount = new BigDecimal(input);
|
||||||
|
if (transactionAmount.compareTo(lineItemsTotal) < 0) {
|
||||||
|
String msg = String.format(
|
||||||
|
"Amount must be at least %s to account for line items.",
|
||||||
|
CurrencyUtil.formatMoney(new MoneyValue(lineItemsTotal, currencyChoiceBox.getValue()))
|
||||||
|
);
|
||||||
|
return ValidationResult.of(msg);
|
||||||
|
}
|
||||||
|
return ValidationResult.valid();
|
||||||
|
}
|
||||||
|
}).validatedInitially().attachToTextField(
|
||||||
|
amountField,
|
||||||
|
currencyChoiceBox.valueProperty(),
|
||||||
|
new SimpleListProperty<>(lineItems)
|
||||||
|
);
|
||||||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||||
initializeTagSelectionUi();
|
initializeTagSelectionUi();
|
||||||
|
initializeLineItemsUi();
|
||||||
|
|
||||||
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
@ -157,6 +161,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendor,
|
vendor,
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
|
lineItems,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -173,6 +178,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendor,
|
vendor,
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
|
lineItems,
|
||||||
existingAttachments,
|
existingAttachments,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
|
@ -195,6 +201,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendorComboBox.setValue(null);
|
vendorComboBox.setValue(null);
|
||||||
categoryComboBox.select(null);
|
categoryComboBox.select(null);
|
||||||
|
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
titleLabel.setText("Create New Transaction");
|
titleLabel.setText("Create New Transaction");
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
|
@ -215,7 +223,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
var accountRepo = ds.getAccountRepository();
|
var accountRepo = ds.getAccountRepository();
|
||||||
var transactionRepo = ds.getTransactionRepository();
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
var vendorRepo = ds.getTransactionVendorRepository();
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
var lineItemRepo = ds.getTransactionLineItemRepository()
|
||||||
) {
|
) {
|
||||||
// First fetch all the data.
|
// First fetch all the data.
|
||||||
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
||||||
|
@ -229,12 +238,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
final CreditAndDebitAccounts linkedAccounts;
|
final CreditAndDebitAccounts linkedAccounts;
|
||||||
final String vendorName;
|
final String vendorName;
|
||||||
final TransactionCategory category;
|
final TransactionCategory category;
|
||||||
|
final List<TransactionLineItem> existingLineItems;
|
||||||
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;
|
||||||
category = null;
|
category = null;
|
||||||
|
existingLineItems = Collections.emptyList();
|
||||||
} else {
|
} else {
|
||||||
attachments = transactionRepo.findAttachments(transaction.id);
|
attachments = transactionRepo.findAttachments(transaction.id);
|
||||||
tags = transactionRepo.findTags(transaction.id);
|
tags = transactionRepo.findTags(transaction.id);
|
||||||
|
@ -250,6 +261,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
} else {
|
} else {
|
||||||
category = null;
|
category = null;
|
||||||
}
|
}
|
||||||
|
existingLineItems = lineItemRepo.findItems(transaction.id);
|
||||||
}
|
}
|
||||||
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||||
// Then make updates to the view.
|
// Then make updates to the view.
|
||||||
|
@ -275,6 +287,9 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||||
}
|
}
|
||||||
|
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
|
||||||
|
lineItemCategoryComboBox.select(null);
|
||||||
|
lineItems.setAll(existingLineItems);
|
||||||
container.setDisable(false);
|
container.setDisable(false);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -291,7 +306,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
return new ValidationApplier<>(getLinkedAccountsValidator())
|
return new ValidationApplier<>(getLinkedAccountsValidator())
|
||||||
.validatedInitially()
|
.validatedInitially()
|
||||||
.attach(linkedAccountsContainer, linkedAccountsProperty);
|
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeTagSelectionUi() {
|
private void initializeTagSelectionUi() {
|
||||||
|
@ -326,6 +341,73 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void initializeLineItemsUi() {
|
||||||
|
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
||||||
|
addLineItemCancelButton.setOnAction(event -> addingLineItemProperty.set(false));
|
||||||
|
addingLineItemProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (!newValue) { // The form has been closed.
|
||||||
|
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
||||||
|
lineItemValueField.setText(null);
|
||||||
|
lineItemDescriptionField.setText(null);
|
||||||
|
lineItemCategoryComboBox.setValue(categoryComboBox.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
||||||
|
BindingUtil.mapContent(lineItemsVBox.getChildren(), lineItems, this::createLineItemTile);
|
||||||
|
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
||||||
|
var lineItemValueValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false))
|
||||||
|
.validatedInitially().attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
||||||
|
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
||||||
|
.addPredicate(s -> s.strip().length() <= TransactionLineItem.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
|
.addPredicate(
|
||||||
|
s -> lineItems.stream().map(TransactionLineItem::getDescription).noneMatch(d -> d.equalsIgnoreCase(s)),
|
||||||
|
"Description must be unique."
|
||||||
|
)
|
||||||
|
).validatedInitially().attachToTextField(lineItemDescriptionField);
|
||||||
|
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
||||||
|
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
||||||
|
addLineItemAddButton.setOnAction(event -> {
|
||||||
|
int quantity = lineItemQuantitySpinner.getValue();
|
||||||
|
BigDecimal valuePerItem = new BigDecimal(lineItemValueField.getText());
|
||||||
|
String description = lineItemDescriptionField.getText().strip();
|
||||||
|
TransactionCategory category = lineItemCategoryComboBox.getValue();
|
||||||
|
Long categoryId = category == null ? null : category.id;
|
||||||
|
long tmpId = tmpLineItemId--;
|
||||||
|
TransactionLineItem tmpItem = new TransactionLineItem(tmpId, -1L, valuePerItem, quantity, -1, description, categoryId);
|
||||||
|
lineItems.add(tmpItem);
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node createLineItemTile(TransactionLineItem item) {
|
||||||
|
TransactionLineItemTile tile = TransactionLineItemTile.build(item, currencyChoiceBox.valueProperty(), categoryComboBox.getItems()).join();
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
removeButton.setOnAction(event -> lineItems.remove(item));
|
||||||
|
Button moveUpButton = new Button("Move Up");
|
||||||
|
moveUpButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveUpButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getFirst().equals(item)));
|
||||||
|
moveUpButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx - 1, item);
|
||||||
|
});
|
||||||
|
Button moveDownButton = new Button("Move Down");
|
||||||
|
moveDownButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveDownButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getLast().equals(item)));
|
||||||
|
moveDownButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx + 1, item);
|
||||||
|
});
|
||||||
|
VBox buttonsBox = new VBox(removeButton, moveUpButton, moveDownButton);
|
||||||
|
buttonsBox.getStyleClass().addAll("std-spacing");
|
||||||
|
tile.setRight(buttonsBox);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||||
return new CreditAndDebitAccounts(
|
return new CreditAndDebitAccounts(
|
||||||
creditAccountSelector.getValue(),
|
creditAccountSelector.getValue(),
|
||||||
|
|
|
@ -7,32 +7,42 @@ import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
|
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.ListProperty;
|
import javafx.beans.property.ListProperty;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleListProperty;
|
import javafx.beans.property.SimpleListProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Hyperlink;
|
import javafx.scene.control.Hyperlink;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.scene.shape.Circle;
|
import javafx.scene.shape.Circle;
|
||||||
import javafx.scene.text.TextFlow;
|
import javafx.scene.text.TextFlow;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class TransactionViewController {
|
public class TransactionViewController {
|
||||||
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
||||||
|
|
||||||
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableValue<Currency> observableCurrency = transactionProperty.map(Transaction::getCurrency);
|
||||||
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
||||||
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
|
||||||
|
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
|
||||||
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
@ -49,6 +59,8 @@ public class TransactionViewController {
|
||||||
@FXML public Hyperlink debitAccountLink;
|
@FXML public Hyperlink debitAccountLink;
|
||||||
@FXML public Hyperlink creditAccountLink;
|
@FXML public Hyperlink creditAccountLink;
|
||||||
|
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
|
|
||||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
|
@ -89,6 +101,26 @@ public class TransactionViewController {
|
||||||
return event -> {};
|
return event -> {};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
VBox lineItemsContainer = (VBox) lineItemsVBox.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsContainer, lineItemsProperty.emptyProperty().not());
|
||||||
|
lineItemsProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
lineItemsVBox.getChildren().clear();
|
||||||
|
Label loadingLabel = new Label("Loading line items...");
|
||||||
|
loadingLabel.getStyleClass().addAll("secondary-color-text-fill");
|
||||||
|
lineItemsVBox.getChildren().add(loadingLabel);
|
||||||
|
List<CompletableFuture<TransactionLineItemTile>> tileFutures = lineItemsList.stream()
|
||||||
|
.map(item -> TransactionLineItemTile.build(item, observableCurrency, null))
|
||||||
|
.toList();
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
List<TransactionLineItemTile> tiles = tileFutures.stream()
|
||||||
|
.map(CompletableFuture::join).toList();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
lineItemsVBox.getChildren().remove(loadingLabel);
|
||||||
|
lineItemsVBox.getChildren().addAll(tiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
attachmentsViewPane.hideIfEmpty();
|
attachmentsViewPane.hideIfEmpty();
|
||||||
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
||||||
|
|
||||||
|
@ -98,6 +130,7 @@ public class TransactionViewController {
|
||||||
vendorProperty.set(null);
|
vendorProperty.set(null);
|
||||||
categoryProperty.set(null);
|
categoryProperty.set(null);
|
||||||
tagsList.clear();
|
tagsList.clear();
|
||||||
|
lineItemsList.clear();
|
||||||
attachmentsList.clear();
|
attachmentsList.clear();
|
||||||
} else {
|
} else {
|
||||||
updateLinkedData(newValue);
|
updateLinkedData(newValue);
|
||||||
|
@ -115,19 +148,22 @@ public class TransactionViewController {
|
||||||
try (
|
try (
|
||||||
var transactionRepo = ds.getTransactionRepository();
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
var vendorRepo = ds.getTransactionVendorRepository();
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
var lineItemsRepo = ds.getTransactionLineItemRepository()
|
||||||
) {
|
) {
|
||||||
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
||||||
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
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 category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
||||||
final var attachments = transactionRepo.findAttachments(tx.id);
|
final var attachments = transactionRepo.findAttachments(tx.id);
|
||||||
final var tags = transactionRepo.findTags(tx.id);
|
final var tags = transactionRepo.findTags(tx.id);
|
||||||
|
final var lineItems = lineItemsRepo.findItems(tx.id);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
linkedAccountsProperty.set(linkedAccounts);
|
linkedAccountsProperty.set(linkedAccounts);
|
||||||
vendorProperty.set(vendor);
|
vendorProperty.set(vendor);
|
||||||
categoryProperty.set(category);
|
categoryProperty.set(category);
|
||||||
attachmentsList.setAll(attachments);
|
attachmentsList.setAll(attachments);
|
||||||
tagsList.setAll(tags);
|
tagsList.setAll(tags);
|
||||||
|
lineItemsList.setAll(lineItems);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to fetch additional transaction data.", e);
|
log.error("Failed to fetch additional transaction data.", e);
|
||||||
|
|
|
@ -32,6 +32,7 @@ public interface DataSource {
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
TransactionVendorRepository getTransactionVendorRepository();
|
TransactionVendorRepository getTransactionVendorRepository();
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
|
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
HistoryRepository getHistoryRepository();
|
HistoryRepository getHistoryRepository();
|
||||||
|
|
||||||
|
@ -87,6 +88,7 @@ public interface DataSource {
|
||||||
TransactionRepository.class, this::getTransactionRepository,
|
TransactionRepository.class, this::getTransactionRepository,
|
||||||
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||||
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||||
|
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
||||||
AttachmentRepository.class, this::getAttachmentRepository,
|
AttachmentRepository.class, this::getAttachmentRepository,
|
||||||
HistoryRepository.class, this::getHistoryRepository,
|
HistoryRepository.class, this::getHistoryRepository,
|
||||||
AnalyticsRepository.class, this::getAnalyticsRepository
|
AnalyticsRepository.class, this::getAnalyticsRepository
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface TransactionLineItemRepository extends Repository, AutoCloseable {
|
||||||
|
List<TransactionLineItem> findItems(long transactionId);
|
||||||
|
List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items);
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -24,6 +25,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
);
|
);
|
||||||
Optional<Transaction> findById(long id);
|
Optional<Transaction> findById(long id);
|
||||||
|
@ -50,6 +52,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
);
|
);
|
||||||
|
|
|
@ -272,7 +272,8 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
String accountNumber = rs.getString("account_number");
|
String accountNumber = rs.getString("account_number");
|
||||||
String name = rs.getString("name");
|
String name = rs.getString("name");
|
||||||
Currency currency = Currency.getInstance(rs.getString("currency"));
|
Currency currency = Currency.getInstance(rs.getString("currency"));
|
||||||
return new Account(id, createdAt, archived, type, accountNumber, name, currency);
|
String description = rs.getString("description");
|
||||||
|
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -59,6 +59,11 @@ public class JdbcDataSource implements DataSource {
|
||||||
return new JdbcTransactionCategoryRepository(getConnection());
|
return new JdbcTransactionCategoryRepository(getConnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionLineItemRepository getTransactionLineItemRepository() {
|
||||||
|
return new JdbcTransactionLineItemRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AttachmentRepository getAttachmentRepository() {
|
public AttachmentRepository getAttachmentRepository() {
|
||||||
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
||||||
|
|
|
@ -35,7 +35,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
|
||||||
* the profile has a newer schema version, we'll exit and prompt the user
|
* the profile has a newer schema version, we'll exit and prompt the user
|
||||||
* to update their app.
|
* to update their app.
|
||||||
*/
|
*/
|
||||||
public static final int SCHEMA_VERSION = 3;
|
public static final int SCHEMA_VERSION = 4;
|
||||||
|
|
||||||
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
||||||
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionLineItemRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record JdbcTransactionLineItemRepository(Connection conn) implements TransactionLineItemRepository {
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> findItems(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_line_item WHERE transaction_id = ? ORDER BY idx ASC",
|
||||||
|
List.of(transactionId),
|
||||||
|
JdbcTransactionLineItemRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items) {
|
||||||
|
// First delete all existing line items since it's just easier that way.
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_line_item WHERE transaction_id = ?", transactionId);
|
||||||
|
if (items.isEmpty()) return Collections.emptyList(); // Skip insertion logic if no items are present.
|
||||||
|
String query = """
|
||||||
|
INSERT INTO transaction_line_item (
|
||||||
|
transaction_id,
|
||||||
|
value_per_item,
|
||||||
|
quantity,
|
||||||
|
idx,
|
||||||
|
description,
|
||||||
|
category_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)""";
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
TransactionLineItem item = items.get(i);
|
||||||
|
stmt.setLong(1, transactionId);
|
||||||
|
stmt.setBigDecimal(2, item.getValuePerItem());
|
||||||
|
stmt.setInt(3, item.getQuantity());
|
||||||
|
stmt.setInt(4, i);
|
||||||
|
stmt.setString(5, item.getDescription());
|
||||||
|
if (item.getCategoryId() == null) {
|
||||||
|
stmt.setNull(6, Types.BIGINT);
|
||||||
|
} else {
|
||||||
|
stmt.setLong(6, item.getCategoryId());
|
||||||
|
}
|
||||||
|
int rowCount = stmt.executeUpdate();
|
||||||
|
if (rowCount != 1) throw new SQLException("Failed to insert line item.");
|
||||||
|
}
|
||||||
|
return findItems(transactionId); // Simply re-fetch items afterward. Their properties may have changed.
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionLineItem parseItem(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
long transactionId = rs.getLong("transaction_id");
|
||||||
|
BigDecimal valuePerItem = rs.getBigDecimal("value_per_item");
|
||||||
|
int quantity = rs.getInt("quantity");
|
||||||
|
int idx = rs.getInt("idx");
|
||||||
|
String description = rs.getString("description");
|
||||||
|
Long categoryId = rs.getLong("category_id");
|
||||||
|
if (rs.wasNull()) categoryId = null;
|
||||||
|
return new TransactionLineItem(id, transactionId, valuePerItem, quantity, idx, description, categoryId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,6 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.*;
|
||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
|
||||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
|
||||||
import com.andrewlalis.perfin.data.pagination.Page;
|
import com.andrewlalis.perfin.data.pagination.Page;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
@ -32,6 +29,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
) {
|
) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
@ -93,6 +91,10 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
// Add Line Items.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
lineItemRepo.saveItems(txId, lineItems);
|
||||||
|
|
||||||
return txId;
|
return txId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -297,6 +299,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
) {
|
) {
|
||||||
|
@ -393,6 +396,13 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
insertAttachmentLink(tx.id, attachment.id);
|
insertAttachmentLink(tx.id, attachment.id);
|
||||||
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
||||||
}
|
}
|
||||||
|
// Manage line item changes.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
List<TransactionLineItem> existingLineItems = lineItemRepo.findItems(tx.id);
|
||||||
|
if (!existingLineItems.equals(lineItems)) {
|
||||||
|
lineItemRepo.saveItems(tx.id, lineItems);
|
||||||
|
updateMessages.add("Updated line items.");
|
||||||
|
}
|
||||||
|
|
||||||
// Add a text history item to any linked accounts detailing the changes.
|
// Add a text history item to any linked accounts detailing the changes.
|
||||||
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
||||||
|
|
|
@ -18,6 +18,7 @@ public class Migrations {
|
||||||
final Map<Integer, Migration> migrations = new HashMap<>();
|
final Map<Integer, Migration> migrations = new HashMap<>();
|
||||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||||
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
||||||
|
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
|
||||||
return migrations;
|
return migrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import java.util.Currency;
|
||||||
* credit-card, etc.).
|
* credit-card, etc.).
|
||||||
*/
|
*/
|
||||||
public class Account extends IdEntity {
|
public class Account extends IdEntity {
|
||||||
|
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
private final LocalDateTime createdAt;
|
private final LocalDateTime createdAt;
|
||||||
private final boolean archived;
|
private final boolean archived;
|
||||||
|
|
||||||
|
@ -15,8 +17,9 @@ public class Account extends IdEntity {
|
||||||
private final String accountNumber;
|
private final String accountNumber;
|
||||||
private final String name;
|
private final String name;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
|
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
super(id);
|
super(id);
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.archived = archived;
|
this.archived = archived;
|
||||||
|
@ -24,6 +27,7 @@ public class Account extends IdEntity {
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountType getType() {
|
public AccountType getType() {
|
||||||
|
@ -62,6 +66,10 @@ public class Account extends IdEntity {
|
||||||
return currency;
|
return currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,14 +16,16 @@ public class TransactionLineItem extends IdEntity {
|
||||||
private final int quantity;
|
private final int quantity;
|
||||||
private final int idx;
|
private final int idx;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
private final Long categoryId;
|
||||||
|
|
||||||
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
|
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description, Long categoryId) {
|
||||||
super(id);
|
super(id);
|
||||||
this.transactionId = transactionId;
|
this.transactionId = transactionId;
|
||||||
this.valuePerItem = valuePerItem;
|
this.valuePerItem = valuePerItem;
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.idx = idx;
|
this.idx = idx;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
this.categoryId = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getTransactionId() {
|
public long getTransactionId() {
|
||||||
|
@ -46,6 +48,10 @@ public class TransactionLineItem extends IdEntity {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal getTotalValue() {
|
public BigDecimal getTotalValue() {
|
||||||
return valuePerItem.multiply(new BigDecimal(quantity));
|
return valuePerItem.multiply(new BigDecimal(quantity));
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@ import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
public class CategoryLabel extends HBox {
|
public class CategoryLabel extends HBox {
|
||||||
public CategoryLabel(TransactionCategory category) {
|
public CategoryLabel(TransactionCategory category) {
|
||||||
|
this(category, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CategoryLabel(TransactionCategory category, double indicatorSize) {
|
||||||
Circle colorIndicator = new Circle(8, category.getColor());
|
Circle colorIndicator = new Circle(8, category.getColor());
|
||||||
Label label = new Label(category.getName());
|
Label label = new Label(category.getName());
|
||||||
this.getChildren().addAll(colorIndicator, label);
|
this.getChildren().addAll(colorIndicator, label);
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
public class TransactionLineItemTile extends BorderPane {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TransactionLineItemTile.class);
|
||||||
|
|
||||||
|
private TransactionLineItemTile() {}
|
||||||
|
|
||||||
|
public static CompletableFuture<TransactionLineItemTile> build(TransactionLineItem item, ObservableValue<Currency> currencyValue, List<TransactionCategory> categoriesCache) {
|
||||||
|
TransactionLineItemTile tile = new TransactionLineItemTile();
|
||||||
|
tile.getStyleClass().addAll("std-spacing", "std-padding", "small-font");
|
||||||
|
tile.setStyle("-fx-background-color: -fx-theme-background-2;");
|
||||||
|
Function<String, Label> boldLabelMaker = s -> {
|
||||||
|
Label lbl = new Label(s);
|
||||||
|
lbl.getStyleClass().addAll("bold-text");
|
||||||
|
return lbl;
|
||||||
|
};
|
||||||
|
Label descriptionLabel = new Label(item.getDescription());
|
||||||
|
Label valuePerItemLabel = new Label();
|
||||||
|
valuePerItemLabel.getStyleClass().add("mono-font");
|
||||||
|
valuePerItemLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getValuePerItem(), currency)))
|
||||||
|
);
|
||||||
|
Label totalValueLabel = new Label();
|
||||||
|
totalValueLabel.getStyleClass().add("mono-font");
|
||||||
|
totalValueLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getTotalValue(), currency)))
|
||||||
|
);
|
||||||
|
Label quantityLabel = new Label(Integer.toString(item.getQuantity()));
|
||||||
|
quantityLabel.getStyleClass().add("mono-font");
|
||||||
|
PropertiesPane propertiesPane = new PropertiesPane(80);
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Description"), descriptionLabel,
|
||||||
|
boldLabelMaker.apply("Quantity"), quantityLabel,
|
||||||
|
boldLabelMaker.apply("Item Value"), valuePerItemLabel,
|
||||||
|
boldLabelMaker.apply("Total"), totalValueLabel
|
||||||
|
);
|
||||||
|
tile.setCenter(propertiesPane);
|
||||||
|
if (item.getCategoryId() != null) {
|
||||||
|
if (categoriesCache != null) {
|
||||||
|
TransactionCategory category = categoriesCache.stream()
|
||||||
|
.filter(c -> c.id == item.getCategoryId())
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
log.warn("Failed to find cached category for line item.");
|
||||||
|
} else {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
} else {
|
||||||
|
CompletableFuture<TransactionLineItemTile> cf = new CompletableFuture<>();
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(item.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> Platform.runLater(() -> {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
cf.complete(tile);
|
||||||
|
}));
|
||||||
|
return cf;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -85,37 +85,38 @@
|
||||||
</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 line items -->
|
||||||
|
<VBox styleClass="std-padding">
|
||||||
|
<Label text="Line Items" styleClass="bold-text"/>
|
||||||
|
<Button text="Add Line Item" fx:id="addLineItemButton"/>
|
||||||
|
<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>
|
||||||
|
<VBox>
|
||||||
|
<Label text="Category" styleClass="bold-text,small-font"/>
|
||||||
|
<CategorySelectionBox fx:id="lineItemCategoryComboBox" maxWidth="Infinity"/>
|
||||||
|
</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" styleClass="std-padding, std-spacing"/>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
<!-- Container for attachments -->
|
<!-- Container for attachments -->
|
||||||
<VBox styleClass="std-padding">
|
<VBox styleClass="std-padding">
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
This migration adds a few things:
|
||||||
|
- A `description` column to the account table, so people can add extra notes and
|
||||||
|
content to their accounts that isn't otherwise captured by the other fields.
|
||||||
|
- A `category_id` is added to transaction line items, so that each line item can
|
||||||
|
individually be marked with a category, so that you can further differentiate
|
||||||
|
large purchases consisting of smaller items.
|
||||||
|
*/
|
||||||
|
ALTER TABLE account
|
||||||
|
ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER currency;
|
||||||
|
|
||||||
|
ALTER TABLE transaction_line_item
|
||||||
|
ADD COLUMN category_id BIGINT DEFAULT NULL AFTER description;
|
||||||
|
|
||||||
|
ALTER TABLE transaction_line_item
|
||||||
|
ADD CONSTRAINT fk_transaction_line_item_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE;
|
|
@ -5,7 +5,8 @@ CREATE TABLE account (
|
||||||
account_type VARCHAR(31) NOT NULL,
|
account_type VARCHAR(31) NOT NULL,
|
||||||
account_number VARCHAR(255) NOT NULL UNIQUE,
|
account_number VARCHAR(255) NOT NULL UNIQUE,
|
||||||
name VARCHAR(63) NOT NULL,
|
name VARCHAR(63) NOT NULL,
|
||||||
currency VARCHAR(3) NOT NULL
|
currency VARCHAR(3) NOT NULL,
|
||||||
|
description VARCHAR(255) DEFAULT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE attachment (
|
CREATE TABLE attachment (
|
||||||
|
@ -102,9 +103,13 @@ CREATE TABLE transaction_line_item (
|
||||||
quantity INT NOT NULL DEFAULT 1,
|
quantity INT NOT NULL DEFAULT 1,
|
||||||
idx INT NOT NULL DEFAULT 0,
|
idx INT NOT NULL DEFAULT 0,
|
||||||
description VARCHAR(255) NOT NULL,
|
description VARCHAR(255) NOT NULL,
|
||||||
|
category_id BIGINT DEFAULT NULL,
|
||||||
CONSTRAINT fk_transaction_line_item_transaction
|
CONSTRAINT fk_transaction_line_item_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_transaction_line_item_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT ck_transaction_line_item_quantity_positive
|
CONSTRAINT ck_transaction_line_item_quantity_positive
|
||||||
CHECK quantity > 0
|
CHECK quantity > 0
|
||||||
);
|
);
|
||||||
|
|
|
@ -74,6 +74,12 @@
|
||||||
<Hyperlink fx:id="creditAccountLink"/>
|
<Hyperlink fx:id="creditAccountLink"/>
|
||||||
</TextFlow>
|
</TextFlow>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
|
||||||
|
<VBox styleClass="std-spacing">
|
||||||
|
<Label text="Line Items" styleClass="bold-text"/>
|
||||||
|
<VBox fx:id="lineItemsVBox" styleClass="std-spacing"/>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
<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"/>
|
||||||
|
|
Loading…
Reference in New Issue