Added the ability to add, edit, and remove transaction line items.

This commit is contained in:
Andrew Lalis 2024-02-09 12:21:06 -05:00
parent 5cc789419c
commit 41530d5276
20 changed files with 439 additions and 73 deletions

View File

@ -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();
}
});
} }
} }

View File

@ -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(),

View File

@ -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);

View File

@ -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

View File

@ -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);
}

View File

@ -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
); );

View File

@ -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

View File

@ -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);

View File

@ -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));

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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));
} }

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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">

View File

@ -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;

View File

@ -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
); );

View File

@ -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"/>