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;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.component.module.*;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.geometry.Bounds;
|
||||
|
@ -34,9 +35,11 @@ public class DashboardController implements RouteSelectionListener {
|
|||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
for (var child : modulesFlowPane.getChildren()) {
|
||||
DashboardModule module = (DashboardModule) child;
|
||||
module.refreshContents();
|
||||
}
|
||||
Profile.whenLoaded(profile -> {
|
||||
for (var child : modulesFlowPane.getChildren()) {
|
||||
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.CategorySelectionBox;
|
||||
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.ValidationResult;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
|
@ -75,11 +74,15 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
||||
@FXML public TextField lineItemValueField;
|
||||
@FXML public TextField lineItemDescriptionField;
|
||||
@FXML public CategorySelectionBox lineItemCategoryComboBox;
|
||||
@FXML public Button addLineItemButton;
|
||||
@FXML public VBox addLineItemForm;
|
||||
@FXML public Button addLineItemAddButton;
|
||||
@FXML public Button addLineItemCancelButton;
|
||||
@FXML public VBox lineItemsVBox;
|
||||
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
||||
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
|
||||
private static long tmpLineItemId = -1L;
|
||||
|
||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||
|
||||
|
@ -96,39 +99,40 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
return ts != null && ts.isBefore(LocalDateTime.now());
|
||||
}, "Timestamp cannot be in the future.")
|
||||
).validatedInitially().attachToTextField(timestampField);
|
||||
var amountValid = new ValidationApplier<>(
|
||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
||||
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
|
||||
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
|
||||
@Override
|
||||
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>()
|
||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||
initializeTagSelectionUi();
|
||||
initializeLineItemsUi();
|
||||
|
||||
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
||||
|
||||
// Initialize line item stuff.
|
||||
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
||||
addLineItemCancelButton.setOnAction(event -> {
|
||||
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
||||
lineItemValueField.setText(null);
|
||||
lineItemDescriptionField.setText(null);
|
||||
addingLineItemProperty.set(false);
|
||||
});
|
||||
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
||||
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
||||
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
||||
var lineItemValueValid = new ValidationApplier<>(
|
||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
||||
).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
||||
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
||||
).attachToTextField(lineItemDescriptionField);
|
||||
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
||||
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
||||
|
||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
}
|
||||
|
@ -157,6 +161,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
vendor,
|
||||
category,
|
||||
tags,
|
||||
lineItems,
|
||||
newAttachmentPaths
|
||||
)
|
||||
);
|
||||
|
@ -173,6 +178,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
vendor,
|
||||
category,
|
||||
tags,
|
||||
lineItems,
|
||||
existingAttachments,
|
||||
newAttachmentPaths
|
||||
)
|
||||
|
@ -195,6 +201,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
vendorComboBox.setValue(null);
|
||||
categoryComboBox.select(null);
|
||||
|
||||
addingLineItemProperty.set(false);
|
||||
|
||||
if (transaction == null) {
|
||||
titleLabel.setText("Create New Transaction");
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
|
@ -215,7 +223,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
var accountRepo = ds.getAccountRepository();
|
||||
var transactionRepo = ds.getTransactionRepository();
|
||||
var vendorRepo = ds.getTransactionVendorRepository();
|
||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
||||
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||
var lineItemRepo = ds.getTransactionLineItemRepository()
|
||||
) {
|
||||
// First fetch all the data.
|
||||
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
||||
|
@ -229,12 +238,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
final CreditAndDebitAccounts linkedAccounts;
|
||||
final String vendorName;
|
||||
final TransactionCategory category;
|
||||
final List<TransactionLineItem> existingLineItems;
|
||||
if (transaction == null) {
|
||||
attachments = Collections.emptyList();
|
||||
tags = Collections.emptyList();
|
||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||
vendorName = null;
|
||||
category = null;
|
||||
existingLineItems = Collections.emptyList();
|
||||
} else {
|
||||
attachments = transactionRepo.findAttachments(transaction.id);
|
||||
tags = transactionRepo.findTags(transaction.id);
|
||||
|
@ -250,6 +261,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
} else {
|
||||
category = null;
|
||||
}
|
||||
existingLineItems = lineItemRepo.findItems(transaction.id);
|
||||
}
|
||||
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||
// Then make updates to the view.
|
||||
|
@ -275,6 +287,9 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||
}
|
||||
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
|
||||
lineItemCategoryComboBox.select(null);
|
||||
lineItems.setAll(existingLineItems);
|
||||
container.setDisable(false);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
|
@ -291,7 +306,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||
return new ValidationApplier<>(getLinkedAccountsValidator())
|
||||
.validatedInitially()
|
||||
.attach(linkedAccountsContainer, linkedAccountsProperty);
|
||||
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
|
||||
}
|
||||
|
||||
private void initializeTagSelectionUi() {
|
||||
|
@ -326,6 +341,73 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
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() {
|
||||
return new CreditAndDebitAccounts(
|
||||
creditAccountSelector.getValue(),
|
||||
|
|
|
@ -7,32 +7,42 @@ import com.andrewlalis.perfin.model.*;
|
|||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Currency;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class TransactionViewController {
|
||||
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
||||
|
||||
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<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
||||
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
||||
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
|
||||
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
|
||||
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public Label titleLabel;
|
||||
|
@ -49,6 +59,8 @@ public class TransactionViewController {
|
|||
@FXML public Hyperlink debitAccountLink;
|
||||
@FXML public Hyperlink creditAccountLink;
|
||||
|
||||
@FXML public VBox lineItemsVBox;
|
||||
|
||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||
|
||||
@FXML public void initialize() {
|
||||
|
@ -89,6 +101,26 @@ public class TransactionViewController {
|
|||
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.listProperty().bindContent(attachmentsList);
|
||||
|
||||
|
@ -98,6 +130,7 @@ public class TransactionViewController {
|
|||
vendorProperty.set(null);
|
||||
categoryProperty.set(null);
|
||||
tagsList.clear();
|
||||
lineItemsList.clear();
|
||||
attachmentsList.clear();
|
||||
} else {
|
||||
updateLinkedData(newValue);
|
||||
|
@ -115,19 +148,22 @@ public class TransactionViewController {
|
|||
try (
|
||||
var transactionRepo = ds.getTransactionRepository();
|
||||
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 vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
||||
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
||||
final var attachments = transactionRepo.findAttachments(tx.id);
|
||||
final var tags = transactionRepo.findTags(tx.id);
|
||||
final var lineItems = lineItemsRepo.findItems(tx.id);
|
||||
Platform.runLater(() -> {
|
||||
linkedAccountsProperty.set(linkedAccounts);
|
||||
vendorProperty.set(vendor);
|
||||
categoryProperty.set(category);
|
||||
attachmentsList.setAll(attachments);
|
||||
tagsList.setAll(tags);
|
||||
lineItemsList.setAll(lineItems);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch additional transaction data.", e);
|
||||
|
|
|
@ -32,6 +32,7 @@ public interface DataSource {
|
|||
TransactionRepository getTransactionRepository();
|
||||
TransactionVendorRepository getTransactionVendorRepository();
|
||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||
AttachmentRepository getAttachmentRepository();
|
||||
HistoryRepository getHistoryRepository();
|
||||
|
||||
|
@ -87,6 +88,7 @@ public interface DataSource {
|
|||
TransactionRepository.class, this::getTransactionRepository,
|
||||
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
||||
AttachmentRepository.class, this::getAttachmentRepository,
|
||||
HistoryRepository.class, this::getHistoryRepository,
|
||||
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.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
|
@ -24,6 +25,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
|||
String vendor,
|
||||
String category,
|
||||
Set<String> tags,
|
||||
List<TransactionLineItem> lineItems,
|
||||
List<Path> attachments
|
||||
);
|
||||
Optional<Transaction> findById(long id);
|
||||
|
@ -50,6 +52,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
|||
String vendor,
|
||||
String category,
|
||||
Set<String> tags,
|
||||
List<TransactionLineItem> lineItems,
|
||||
List<Attachment> existingAttachments,
|
||||
List<Path> newAttachmentPaths
|
||||
);
|
||||
|
|
|
@ -272,7 +272,8 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
String accountNumber = rs.getString("account_number");
|
||||
String name = rs.getString("name");
|
||||
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
|
||||
|
|
|
@ -59,6 +59,11 @@ public class JdbcDataSource implements DataSource {
|
|||
return new JdbcTransactionCategoryRepository(getConnection());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionLineItemRepository getTransactionLineItemRepository() {
|
||||
return new JdbcTransactionLineItemRepository(getConnection());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttachmentRepository getAttachmentRepository() {
|
||||
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
|
||||
* 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 {
|
||||
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;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.*;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
|
@ -32,6 +29,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
String vendor,
|
||||
String category,
|
||||
Set<String> tags,
|
||||
List<TransactionLineItem> lineItems,
|
||||
List<Path> attachments
|
||||
) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
@ -297,6 +299,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
String vendor,
|
||||
String category,
|
||||
Set<String> tags,
|
||||
List<TransactionLineItem> lineItems,
|
||||
List<Attachment> existingAttachments,
|
||||
List<Path> newAttachmentPaths
|
||||
) {
|
||||
|
@ -393,6 +396,13 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
insertAttachmentLink(tx.id, attachment.id);
|
||||
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.
|
||||
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<>();
|
||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
||||
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
|
||||
return migrations;
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import java.util.Currency;
|
|||
* credit-card, etc.).
|
||||
*/
|
||||
public class Account extends IdEntity {
|
||||
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
private final LocalDateTime createdAt;
|
||||
private final boolean archived;
|
||||
|
||||
|
@ -15,8 +17,9 @@ public class Account extends IdEntity {
|
|||
private final String accountNumber;
|
||||
private final String name;
|
||||
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);
|
||||
this.createdAt = createdAt;
|
||||
this.archived = archived;
|
||||
|
@ -24,6 +27,7 @@ public class Account extends IdEntity {
|
|||
this.accountNumber = accountNumber;
|
||||
this.name = name;
|
||||
this.currency = currency;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public AccountType getType() {
|
||||
|
@ -62,6 +66,10 @@ public class Account extends IdEntity {
|
|||
return currency;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
|
|
@ -16,14 +16,16 @@ public class TransactionLineItem extends IdEntity {
|
|||
private final int quantity;
|
||||
private final int idx;
|
||||
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);
|
||||
this.transactionId = transactionId;
|
||||
this.valuePerItem = valuePerItem;
|
||||
this.quantity = quantity;
|
||||
this.idx = idx;
|
||||
this.description = description;
|
||||
this.categoryId = categoryId;
|
||||
}
|
||||
|
||||
public long getTransactionId() {
|
||||
|
@ -46,6 +48,10 @@ public class TransactionLineItem extends IdEntity {
|
|||
return description;
|
||||
}
|
||||
|
||||
public Long getCategoryId() {
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
public BigDecimal getTotalValue() {
|
||||
return valuePerItem.multiply(new BigDecimal(quantity));
|
||||
}
|
||||
|
|
|
@ -7,6 +7,10 @@ import javafx.scene.shape.Circle;
|
|||
|
||||
public class CategoryLabel extends HBox {
|
||||
public CategoryLabel(TransactionCategory category) {
|
||||
this(category, 8);
|
||||
}
|
||||
|
||||
public CategoryLabel(TransactionCategory category, double indicatorSize) {
|
||||
Circle colorIndicator = new Circle(8, category.getColor());
|
||||
Label label = new Label(category.getName());
|
||||
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>
|
||||
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
|
||||
</VBox>
|
||||
|
||||
<Label text="Line Items" styleClass="bold-text"/>
|
||||
<VBox maxWidth="Infinity">
|
||||
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
|
||||
<StyledText styleClass="small-font">
|
||||
Line items aren't yet supported. I'm working on it!
|
||||
</StyledText>
|
||||
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
|
||||
<HBox styleClass="std-spacing">
|
||||
<VBox>
|
||||
<Label text="Quantity" styleClass="bold-text,small-font"/>
|
||||
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
|
||||
</VBox>
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<Label text="Value per Item" styleClass="bold-text,small-font"/>
|
||||
<TextField fx:id="lineItemValueField"/>
|
||||
</VBox>
|
||||
</HBox>
|
||||
<VBox>
|
||||
<Label text="Description" styleClass="bold-text,small-font"/>
|
||||
<TextField fx:id="lineItemDescriptionField"/>
|
||||
</VBox>
|
||||
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Add" fx:id="addLineItemAddButton"/>
|
||||
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
|
||||
<VBox fx:id="lineItemsVBox"/>
|
||||
</VBox>
|
||||
</PropertiesPane>
|
||||
<!-- Container for 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 -->
|
||||
<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_number VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(63) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
description VARCHAR(255) DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE attachment (
|
||||
|
@ -102,9 +103,13 @@ CREATE TABLE transaction_line_item (
|
|||
quantity INT NOT NULL DEFAULT 1,
|
||||
idx INT NOT NULL DEFAULT 0,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
category_id BIGINT DEFAULT NULL,
|
||||
CONSTRAINT fk_transaction_line_item_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
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
|
||||
CHECK quantity > 0
|
||||
);
|
||||
|
|
|
@ -74,6 +74,12 @@
|
|||
<Hyperlink fx:id="creditAccountLink"/>
|
||||
</TextFlow>
|
||||
</VBox>
|
||||
|
||||
<VBox styleClass="std-spacing">
|
||||
<Label text="Line Items" styleClass="bold-text"/>
|
||||
<VBox fx:id="lineItemsVBox" styleClass="std-spacing"/>
|
||||
</VBox>
|
||||
|
||||
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
|
||||
<Button text="Edit" onAction="#editTransaction"/>
|
||||
|
|
Loading…
Reference in New Issue