Added ability to edit tags, vendor, and category of a transaction.
This commit is contained in:
		
							parent
							
								
									e17e2c55a5
								
							
						
					
					
						commit
						b9678313bf
					
				| 
						 | 
				
			
			@ -58,7 +58,7 @@ public class PerfinApp extends Application {
 | 
			
		|||
                PerfinApp::initAppDir,
 | 
			
		||||
                c -> initMainScreen(stage, c),
 | 
			
		||||
                PerfinApp::loadLastUsedProfile
 | 
			
		||||
        ));
 | 
			
		||||
        ), false);
 | 
			
		||||
        splashScreen.showAndWait();
 | 
			
		||||
        if (splashScreen.isStartupSuccessful()) {
 | 
			
		||||
            stage.show();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,14 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Sort;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.*;
 | 
			
		||||
import com.andrewlalis.perfin.view.BindingUtil;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
| 
						 | 
				
			
			@ -15,10 +17,16 @@ import com.andrewlalis.perfin.view.component.validation.validators.PredicateVali
 | 
			
		|||
import javafx.application.Platform;
 | 
			
		||||
import javafx.beans.property.Property;
 | 
			
		||||
import javafx.beans.property.SimpleObjectProperty;
 | 
			
		||||
import javafx.collections.FXCollections;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.event.ActionEvent;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.geometry.Pos;
 | 
			
		||||
import javafx.scene.control.*;
 | 
			
		||||
import javafx.scene.input.KeyCode;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.HBox;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -27,10 +35,7 @@ import java.nio.file.Path;
 | 
			
		|||
import java.time.DateTimeException;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.time.format.DateTimeFormatter;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.Comparator;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.*;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +54,13 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
    @FXML public AccountSelectionBox debitAccountSelector;
 | 
			
		||||
    @FXML public AccountSelectionBox creditAccountSelector;
 | 
			
		||||
 | 
			
		||||
    @FXML public ComboBox<String> vendorComboBox;
 | 
			
		||||
    @FXML public ComboBox<String> categoryComboBox;
 | 
			
		||||
    @FXML public ComboBox<String> tagsComboBox;
 | 
			
		||||
    @FXML public Button addTagButton;
 | 
			
		||||
    @FXML public VBox tagsVBox;
 | 
			
		||||
    private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public FileSelectionArea attachmentsSelectionArea;
 | 
			
		||||
 | 
			
		||||
    @FXML public Button saveButton;
 | 
			
		||||
| 
						 | 
				
			
			@ -75,27 +87,40 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
 | 
			
		||||
        debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
			
		||||
        creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
			
		||||
        var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
 | 
			
		||||
                .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
			
		||||
                        "The credit and debit accounts cannot be the same."
 | 
			
		||||
                )
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (
 | 
			
		||||
                                (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
			
		||||
                                (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
			
		||||
                        ),
 | 
			
		||||
                        "Linked accounts must use the same currency."
 | 
			
		||||
                )
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (
 | 
			
		||||
                                (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
			
		||||
                                (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
			
		||||
                        ),
 | 
			
		||||
                        "Linked accounts must not be archived."
 | 
			
		||||
                )
 | 
			
		||||
        ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
 | 
			
		||||
        var linkedAccountsValid = new ValidationApplier<>(getLinkedAccountsValidator())
 | 
			
		||||
                .validatedInitially()
 | 
			
		||||
                .attach(linkedAccountsContainer, linkedAccountsProperty);
 | 
			
		||||
 | 
			
		||||
        // Set up the list of added tags.
 | 
			
		||||
        addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
 | 
			
		||||
        addTagButton.setOnAction(event -> {
 | 
			
		||||
            if (tagsComboBox.getValue() == null) return;
 | 
			
		||||
            String tag = tagsComboBox.getValue().strip();
 | 
			
		||||
            if (!selectedTags.contains(tag)) {
 | 
			
		||||
                selectedTags.add(tag);
 | 
			
		||||
                selectedTags.sort(String::compareToIgnoreCase);
 | 
			
		||||
            }
 | 
			
		||||
            tagsComboBox.setValue(null);
 | 
			
		||||
        });
 | 
			
		||||
        tagsComboBox.setOnKeyPressed(event -> {
 | 
			
		||||
            if (event.getCode() == KeyCode.ENTER) {
 | 
			
		||||
                addTagButton.fire();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, tag -> {
 | 
			
		||||
            Label label = new Label(tag);
 | 
			
		||||
            label.setMaxWidth(Double.POSITIVE_INFINITY);
 | 
			
		||||
            label.getStyleClass().addAll("bold-text");
 | 
			
		||||
            Button removeButton = new Button("Remove");
 | 
			
		||||
            removeButton.setOnAction(event -> {
 | 
			
		||||
                selectedTags.remove(tag);
 | 
			
		||||
            });
 | 
			
		||||
            BorderPane tile = new BorderPane(label);
 | 
			
		||||
            tile.setRight(removeButton);
 | 
			
		||||
            tile.getStyleClass().addAll("std-spacing");
 | 
			
		||||
            BorderPane.setAlignment(label, Pos.CENTER_LEFT);
 | 
			
		||||
            return tile;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
 | 
			
		||||
        saveButton.disableProperty().bind(formValid.not());
 | 
			
		||||
| 
						 | 
				
			
			@ -107,6 +132,9 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        Currency currency = currencyChoiceBox.getValue();
 | 
			
		||||
        String description = getSanitizedDescription();
 | 
			
		||||
        CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
 | 
			
		||||
        String vendor = vendorComboBox.getValue();
 | 
			
		||||
        String category = categoryComboBox.getValue();
 | 
			
		||||
        Set<String> tags = new HashSet<>(selectedTags);
 | 
			
		||||
        List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
 | 
			
		||||
        List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
 | 
			
		||||
        final long idToNavigate;
 | 
			
		||||
| 
						 | 
				
			
			@ -119,6 +147,9 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                    currency,
 | 
			
		||||
                    description,
 | 
			
		||||
                    linkedAccounts,
 | 
			
		||||
                    vendor,
 | 
			
		||||
                    category,
 | 
			
		||||
                    tags,
 | 
			
		||||
                    newAttachmentPaths
 | 
			
		||||
                )
 | 
			
		||||
            );
 | 
			
		||||
| 
						 | 
				
			
			@ -132,6 +163,9 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                        currency,
 | 
			
		||||
                        description,
 | 
			
		||||
                        linkedAccounts,
 | 
			
		||||
                        vendor,
 | 
			
		||||
                        category,
 | 
			
		||||
                        tags,
 | 
			
		||||
                        existingAttachments,
 | 
			
		||||
                        newAttachmentPaths
 | 
			
		||||
                )
 | 
			
		||||
| 
						 | 
				
			
			@ -149,6 +183,11 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        transaction = (Transaction) context;
 | 
			
		||||
 | 
			
		||||
        // Clear some initial fields immediately:
 | 
			
		||||
        tagsComboBox.setValue(null);
 | 
			
		||||
        vendorComboBox.setValue(null);
 | 
			
		||||
        categoryComboBox.setValue(null);
 | 
			
		||||
 | 
			
		||||
        if (transaction == null) {
 | 
			
		||||
            titleLabel.setText("Create New Transaction");
 | 
			
		||||
            timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
			
		||||
| 
						 | 
				
			
			@ -163,10 +202,13 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
 | 
			
		||||
        // Fetch some account-specific data.
 | 
			
		||||
        container.setDisable(true);
 | 
			
		||||
        DataSource ds = Profile.getCurrent().dataSource();
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            try (
 | 
			
		||||
                    var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
 | 
			
		||||
                    var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository()
 | 
			
		||||
                    var accountRepo = ds.getAccountRepository();
 | 
			
		||||
                    var transactionRepo = ds.getTransactionRepository();
 | 
			
		||||
                    var vendorRepo = ds.getTransactionVendorRepository();
 | 
			
		||||
                    var categoryRepo = ds.getTransactionCategoryRepository()
 | 
			
		||||
            ) {
 | 
			
		||||
                // First fetch all the data.
 | 
			
		||||
                List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
 | 
			
		||||
| 
						 | 
				
			
			@ -174,23 +216,50 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                        .toList();
 | 
			
		||||
                List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
			
		||||
                final List<Attachment> attachments;
 | 
			
		||||
                final List<String> availableTags = transactionRepo.findAllTags();
 | 
			
		||||
                final List<String> tags;
 | 
			
		||||
                final CreditAndDebitAccounts linkedAccounts;
 | 
			
		||||
                final String vendorName;
 | 
			
		||||
                final String categoryName;
 | 
			
		||||
                if (transaction == null) {
 | 
			
		||||
                    attachments = Collections.emptyList();
 | 
			
		||||
                    tags = Collections.emptyList();
 | 
			
		||||
                    linkedAccounts = new CreditAndDebitAccounts(null, null);
 | 
			
		||||
                    vendorName = null;
 | 
			
		||||
                    categoryName = null;
 | 
			
		||||
                } else {
 | 
			
		||||
                    attachments = transactionRepo.findAttachments(transaction.id);
 | 
			
		||||
                    tags = transactionRepo.findTags(transaction.id);
 | 
			
		||||
                    linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
 | 
			
		||||
                    if (transaction.getVendorId() != null) {
 | 
			
		||||
                        vendorName = vendorRepo.findById(transaction.getVendorId())
 | 
			
		||||
                                .map(TransactionVendor::getName).orElse(null);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        vendorName = null;
 | 
			
		||||
                    }
 | 
			
		||||
                    if (transaction.getCategoryId() != null) {
 | 
			
		||||
                        categoryName = categoryRepo.findById(transaction.getCategoryId())
 | 
			
		||||
                                .map(TransactionCategory::getName).orElse(null);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        categoryName = null;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                final List<TransactionVendor> availableVendors = vendorRepo.findAll();
 | 
			
		||||
                final List<TransactionCategory> availableCategories = categoryRepo.findAll();
 | 
			
		||||
                // Then make updates to the view.
 | 
			
		||||
                Platform.runLater(() -> {
 | 
			
		||||
                    currencyChoiceBox.getItems().setAll(currencies);
 | 
			
		||||
                    creditAccountSelector.setAccounts(accounts);
 | 
			
		||||
                    debitAccountSelector.setAccounts(accounts);
 | 
			
		||||
                    currencyChoiceBox.getItems().setAll(currencies);
 | 
			
		||||
                    vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
 | 
			
		||||
                    vendorComboBox.setValue(vendorName);
 | 
			
		||||
                    categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList());
 | 
			
		||||
                    categoryComboBox.setValue(categoryName);
 | 
			
		||||
                    tagsComboBox.getItems().setAll(availableTags);
 | 
			
		||||
                    attachmentsSelectionArea.clear();
 | 
			
		||||
                    attachmentsSelectionArea.addAttachments(attachments);
 | 
			
		||||
                    selectedTags.clear();
 | 
			
		||||
                    if (transaction == null) {
 | 
			
		||||
                        // TODO: Allow user to select a default currency.
 | 
			
		||||
                        currencyChoiceBox.getSelectionModel().selectFirst();
 | 
			
		||||
                        creditAccountSelector.select(null);
 | 
			
		||||
                        debitAccountSelector.select(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -198,12 +267,14 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                        currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
 | 
			
		||||
                        creditAccountSelector.select(linkedAccounts.creditAccount());
 | 
			
		||||
                        debitAccountSelector.select(linkedAccounts.debitAccount());
 | 
			
		||||
                        selectedTags.addAll(tags);
 | 
			
		||||
                    }
 | 
			
		||||
                    container.setDisable(false);
 | 
			
		||||
                });
 | 
			
		||||
            } catch (Exception e) {
 | 
			
		||||
                log.error("Failed to get repositories.", e);
 | 
			
		||||
                Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage());
 | 
			
		||||
                Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
 | 
			
		||||
                router.navigateBackAndClear();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -215,6 +286,29 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
 | 
			
		||||
        return new PredicateValidator<CreditAndDebitAccounts>()
 | 
			
		||||
            .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
			
		||||
                "The credit and debit accounts cannot be the same."
 | 
			
		||||
            )
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (
 | 
			
		||||
                    (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
			
		||||
                    (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
			
		||||
                ),
 | 
			
		||||
                "Linked accounts must use the same currency."
 | 
			
		||||
            )
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (
 | 
			
		||||
                    (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
			
		||||
                    (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
			
		||||
                ),
 | 
			
		||||
                "Linked accounts must not be archived."
 | 
			
		||||
            );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private LocalDateTime parseTimestamp() {
 | 
			
		||||
        List<DateTimeFormatter> formatters = List.of(
 | 
			
		||||
                DateTimeFormatter.ISO_LOCAL_DATE_TIME,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -106,6 +106,7 @@ public class ProfilesViewController {
 | 
			
		|||
        log.info("Opening profile \"{}\".", name);
 | 
			
		||||
        try {
 | 
			
		||||
            Profile.setCurrent(PerfinApp.profileLoader.load(name));
 | 
			
		||||
            ProfileLoader.saveLastProfile(name);
 | 
			
		||||
            ProfilesStage.closeView();
 | 
			
		||||
            router.replace("accounts");
 | 
			
		||||
            if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,8 @@ public interface DataSource {
 | 
			
		|||
    AccountRepository getAccountRepository();
 | 
			
		||||
    BalanceRecordRepository getBalanceRecordRepository();
 | 
			
		||||
    TransactionRepository getTransactionRepository();
 | 
			
		||||
    TransactionVendorRepository getTransactionVendorRepository();
 | 
			
		||||
    TransactionCategoryRepository getTransactionCategoryRepository();
 | 
			
		||||
    AttachmentRepository getAttachmentRepository();
 | 
			
		||||
    AccountHistoryItemRepository getAccountHistoryItemRepository();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -81,6 +83,8 @@ public interface DataSource {
 | 
			
		|||
                AccountRepository.class, this::getAccountRepository,
 | 
			
		||||
                BalanceRecordRepository.class, this::getBalanceRecordRepository,
 | 
			
		||||
                TransactionRepository.class, this::getTransactionRepository,
 | 
			
		||||
                TransactionVendorRepository.class, this::getTransactionVendorRepository,
 | 
			
		||||
                TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
 | 
			
		||||
                AttachmentRepository.class, this::getAttachmentRepository,
 | 
			
		||||
                AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
 | 
			
		||||
    Optional<TransactionCategory> findById(long id);
 | 
			
		||||
    Optional<TransactionCategory> findByName(String name);
 | 
			
		||||
    List<TransactionCategory> findAllBaseCategories();
 | 
			
		||||
    List<TransactionCategory> findAll();
 | 
			
		||||
    long insert(long parentId, String name, Color color);
 | 
			
		||||
    long insert(String name, Color color);
 | 
			
		||||
    void deleteById(long id);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Path> attachments
 | 
			
		||||
    );
 | 
			
		||||
    Optional<Transaction> findById(long id);
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
    Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
 | 
			
		||||
    CreditAndDebitAccounts findLinkedAccounts(long transactionId);
 | 
			
		||||
    List<Attachment> findAttachments(long transactionId);
 | 
			
		||||
    List<String> findTags(long transactionId);
 | 
			
		||||
    List<String> findAllTags();
 | 
			
		||||
    void delete(long transactionId);
 | 
			
		||||
    void update(
 | 
			
		||||
            long id,
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Attachment> existingAttachments,
 | 
			
		||||
            List<Path> newAttachmentPaths
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public interface TransactionVendorRepository extends Repository, AutoCloseable {
 | 
			
		||||
    Optional<TransactionVendor> findById(long id);
 | 
			
		||||
    Optional<TransactionVendor> findByName(String name);
 | 
			
		||||
    List<TransactionVendor> findAll();
 | 
			
		||||
    long insert(String name, String description);
 | 
			
		||||
    long insert(String name);
 | 
			
		||||
    void deleteById(long id);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +49,16 @@ public class JdbcDataSource implements DataSource {
 | 
			
		|||
        return new JdbcTransactionRepository(getConnection(), contentDir);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public TransactionVendorRepository getTransactionVendorRepository() {
 | 
			
		||||
        return new JdbcTransactionVendorRepository(getConnection());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public TransactionCategoryRepository getTransactionCategoryRepository() {
 | 
			
		||||
        return new JdbcTransactionCategoryRepository(getConnection());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public AttachmentRepository getAttachmentRepository() {
 | 
			
		||||
        return new JdbcAttachmentRepository(getConnection(), contentDir);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,90 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.ColorUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionCategory> findById(long id) {
 | 
			
		||||
        return DbUtil.findById(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE id = ?",
 | 
			
		||||
                id,
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionCategory> findByName(String name) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionCategory> findAllBaseCategories() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionCategory> findAll() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(long parentId, String name, Color color) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
 | 
			
		||||
                List.of(parentId, name, ColorUtil.toHex(color))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name, Color color) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_category (name, color) VALUES (?, ?)",
 | 
			
		||||
                List.of(name, ColorUtil.toHex(color))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteById(long id) {
 | 
			
		||||
        DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new TransactionCategory(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                rs.getObject("parent_id", Long.class),
 | 
			
		||||
                rs.getString("name"),
 | 
			
		||||
                Color.valueOf("#" + rs.getString("color"))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -8,14 +8,14 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		|||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
 | 
			
		||||
import com.andrewlalis.perfin.model.*;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.math.RoundingMode;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.sql.*;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.*;
 | 
			
		||||
import java.util.stream.Collectors;
 | 
			
		||||
| 
						 | 
				
			
			@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Path> attachments
 | 
			
		||||
    ) {
 | 
			
		||||
        return DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            // 1. Insert the transaction.
 | 
			
		||||
            long txId = DbUtil.insertOne(
 | 
			
		||||
                    conn,
 | 
			
		||||
                    "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
 | 
			
		||||
                    List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
 | 
			
		||||
            );
 | 
			
		||||
            // 2. Insert linked account entries.
 | 
			
		||||
            Long vendorId = null;
 | 
			
		||||
            if (vendor != null && !vendor.isBlank()) {
 | 
			
		||||
                vendorId = getOrCreateVendorId(vendor.strip());
 | 
			
		||||
            }
 | 
			
		||||
            Long categoryId = null;
 | 
			
		||||
            if (category != null && !category.isBlank()) {
 | 
			
		||||
                categoryId = getOrCreateCategoryId(category.strip());
 | 
			
		||||
            }
 | 
			
		||||
            // Insert the transaction, using a custom JDBC statement to deal with nullables.
 | 
			
		||||
            long txId;
 | 
			
		||||
            try (var stmt = conn.prepareStatement(
 | 
			
		||||
                    "INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
 | 
			
		||||
                    Statement.RETURN_GENERATED_KEYS
 | 
			
		||||
            )) {
 | 
			
		||||
                stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
 | 
			
		||||
                stmt.setBigDecimal(2, amount);
 | 
			
		||||
                stmt.setString(3, currency.getCurrencyCode());
 | 
			
		||||
                if (description != null && !description.isBlank()) {
 | 
			
		||||
                    stmt.setString(4, description.strip());
 | 
			
		||||
                } else {
 | 
			
		||||
                    stmt.setNull(4, Types.VARCHAR);
 | 
			
		||||
                }
 | 
			
		||||
                if (vendorId != null) {
 | 
			
		||||
                    stmt.setLong(5, vendorId);
 | 
			
		||||
                } else {
 | 
			
		||||
                    stmt.setNull(5, Types.BIGINT);
 | 
			
		||||
                }
 | 
			
		||||
                if (categoryId != null) {
 | 
			
		||||
                    stmt.setLong(6, categoryId);
 | 
			
		||||
                } else {
 | 
			
		||||
                    stmt.setNull(6, Types.BIGINT);
 | 
			
		||||
                }
 | 
			
		||||
                int result = stmt.executeUpdate();
 | 
			
		||||
                if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
 | 
			
		||||
                var rs = stmt.getGeneratedKeys();
 | 
			
		||||
                if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
 | 
			
		||||
                txId = rs.getLong(1);
 | 
			
		||||
            }
 | 
			
		||||
            // Insert linked account entries.
 | 
			
		||||
            AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
 | 
			
		||||
            linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
 | 
			
		||||
            linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
 | 
			
		||||
            // 3. Add attachments.
 | 
			
		||||
            // Add attachments.
 | 
			
		||||
            AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
			
		||||
            for (Path attachmentPath : attachments) {
 | 
			
		||||
                Attachment attachment = attachmentRepo.insert(attachmentPath);
 | 
			
		||||
                insertAttachmentLink(txId, attachment.id);
 | 
			
		||||
            }
 | 
			
		||||
            // Add tags.
 | 
			
		||||
            for (String tag : tags) {
 | 
			
		||||
                try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
 | 
			
		||||
                    long tagId = getOrCreateTagId(tag.toLowerCase().strip());
 | 
			
		||||
                    stmt.setLong(1, txId);
 | 
			
		||||
                    stmt.setLong(2, tagId);
 | 
			
		||||
                    stmt.executeUpdate();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            }
 | 
			
		||||
            return txId;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long getOrCreateVendorId(String name) {
 | 
			
		||||
        var repo = new JdbcTransactionVendorRepository(conn);
 | 
			
		||||
        TransactionVendor vendor = repo.findByName(name).orElse(null);
 | 
			
		||||
        if (vendor != null) {
 | 
			
		||||
            return vendor.id;
 | 
			
		||||
        }
 | 
			
		||||
        return repo.insert(name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long getOrCreateCategoryId(String name) {
 | 
			
		||||
        var repo = new JdbcTransactionCategoryRepository(conn);
 | 
			
		||||
        TransactionCategory category = repo.findByName(name).orElse(null);
 | 
			
		||||
        if (category != null) {
 | 
			
		||||
            return category.id;
 | 
			
		||||
        }
 | 
			
		||||
        return repo.insert(name, Color.WHITE);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long getOrCreateTagId(String name) {
 | 
			
		||||
        Optional<Long> optionalId = DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT id FROM transaction_tag WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                rs -> rs.getLong(1)
 | 
			
		||||
        );
 | 
			
		||||
        return optionalId.orElseGet(() ->
 | 
			
		||||
                DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<Transaction> findById(long id) {
 | 
			
		||||
        return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
 | 
			
		||||
| 
						 | 
				
			
			@ -147,6 +222,30 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<String> findTags(long transactionId) {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                        SELECT tt.name
 | 
			
		||||
                        FROM transaction_tag tt
 | 
			
		||||
                        LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
 | 
			
		||||
                        WHERE ttj.transaction_id = ?
 | 
			
		||||
                        ORDER BY tt.name ASC""",
 | 
			
		||||
                List.of(transactionId),
 | 
			
		||||
                rs -> rs.getString(1)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<String> findAllTags() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT name FROM transaction_tag ORDER BY name ASC",
 | 
			
		||||
                rs -> rs.getString(1)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void delete(long transactionId) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
| 
						 | 
				
			
			@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Attachment> existingAttachments,
 | 
			
		||||
            List<Path> newAttachmentPaths
 | 
			
		||||
    ) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            Transaction tx = findById(id).orElseThrow();
 | 
			
		||||
            CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
 | 
			
		||||
            List<Attachment> currentAttachments = findAttachments(id);
 | 
			
		||||
            var entryRepo = new JdbcAccountEntryRepository(conn);
 | 
			
		||||
            var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
			
		||||
            var vendorRepo = new JdbcTransactionVendorRepository(conn);
 | 
			
		||||
            var categoryRepo = new JdbcTransactionCategoryRepository(conn);
 | 
			
		||||
 | 
			
		||||
            Transaction tx = findById(id).orElseThrow();
 | 
			
		||||
            CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
 | 
			
		||||
            TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
 | 
			
		||||
            String currentVendorName = currentVendor == null ? null : currentVendor.getName();
 | 
			
		||||
            TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
 | 
			
		||||
            String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
 | 
			
		||||
            Set<String> currentTags = new HashSet<>(findTags(id));
 | 
			
		||||
            List<Attachment> currentAttachments = findAttachments(id);
 | 
			
		||||
 | 
			
		||||
            List<String> updateMessages = new ArrayList<>();
 | 
			
		||||
            if (!tx.getTimestamp().equals(utcTimestamp)) {
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id));
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
 | 
			
		||||
                updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
 | 
			
		||||
            }
 | 
			
		||||
            BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
 | 
			
		||||
            if (!tx.getAmount().equals(scaledAmount)) {
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id));
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
 | 
			
		||||
                updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
 | 
			
		||||
            }
 | 
			
		||||
            if (!tx.getCurrency().equals(currency)) {
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id));
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
 | 
			
		||||
                updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
 | 
			
		||||
            }
 | 
			
		||||
            if (!Objects.equals(tx.getDescription(), description)) {
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id));
 | 
			
		||||
                DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
 | 
			
		||||
                updateMessages.add("Updated description.");
 | 
			
		||||
            }
 | 
			
		||||
            boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
 | 
			
		||||
            boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
 | 
			
		||||
                    !tx.getCurrency().equals(currency) ||
 | 
			
		||||
                    !tx.getTimestamp().equals(utcTimestamp) ||
 | 
			
		||||
                    !currentLinkedAccounts.equals(linkedAccounts);
 | 
			
		||||
            if (updateAccountEntries) {
 | 
			
		||||
                // Delete all entries and re-write them correctly?
 | 
			
		||||
                DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id));
 | 
			
		||||
            if (shouldUpdateAccountEntries) {
 | 
			
		||||
                // Delete all entries and re-write them correctly.
 | 
			
		||||
                DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
 | 
			
		||||
                linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
 | 
			
		||||
                linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
 | 
			
		||||
                updateMessages.add("Updated linked accounts.");
 | 
			
		||||
            }
 | 
			
		||||
            // Manage vendor change.
 | 
			
		||||
            if (!Objects.equals(vendor, currentVendorName)) {
 | 
			
		||||
                if (vendor == null || vendor.isBlank()) {
 | 
			
		||||
                    DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
 | 
			
		||||
                } else {
 | 
			
		||||
                    long newVendorId = getOrCreateVendorId(vendor);
 | 
			
		||||
                    DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
 | 
			
		||||
                }
 | 
			
		||||
                updateMessages.add("Updated vendor name to \"" + vendor + "\".");
 | 
			
		||||
            }
 | 
			
		||||
            // Manage category change.
 | 
			
		||||
            if (!Objects.equals(category, currentCategoryName)) {
 | 
			
		||||
                if (category == null || category.isBlank()) {
 | 
			
		||||
                    DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
 | 
			
		||||
                } else {
 | 
			
		||||
                    long newCategoryId = getOrCreateCategoryId(category);
 | 
			
		||||
                    DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
 | 
			
		||||
                }
 | 
			
		||||
                updateMessages.add("Updated category name to \"" + category + "\".");
 | 
			
		||||
            }
 | 
			
		||||
            // Manage tags changes.
 | 
			
		||||
            if (!currentTags.equals(tags)) {
 | 
			
		||||
                Set<String> tagsAdded = new HashSet<>(tags);
 | 
			
		||||
                tagsAdded.removeAll(currentTags);
 | 
			
		||||
                Set<String> tagsRemoved = new HashSet<>(currentTags);
 | 
			
		||||
                tagsRemoved.removeAll(tags);
 | 
			
		||||
 | 
			
		||||
                for (var t : tagsRemoved) removeTag(id, t);
 | 
			
		||||
                for (var t : tagsAdded) addTag(id, t);
 | 
			
		||||
 | 
			
		||||
                if (!tagsAdded.isEmpty()) {
 | 
			
		||||
                    updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
 | 
			
		||||
                }
 | 
			
		||||
                if (!tagsRemoved.isEmpty()) {
 | 
			
		||||
                    updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            // Manage attachments changes.
 | 
			
		||||
            List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
 | 
			
		||||
            removedAttachments.removeAll(existingAttachments);
 | 
			
		||||
| 
						 | 
				
			
			@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
                insertAttachmentLink(tx.id, attachment.id);
 | 
			
		||||
                updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Add a text history item to any linked accounts detailing the changes.
 | 
			
		||||
            String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
 | 
			
		||||
            var historyRepo = new JdbcAccountHistoryItemRepository(conn);
 | 
			
		||||
            linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
 | 
			
		||||
| 
						 | 
				
			
			@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Transaction parseTransaction(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new Transaction(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
			
		||||
                rs.getBigDecimal("amount"),
 | 
			
		||||
                Currency.getInstance(rs.getString("currency")),
 | 
			
		||||
                rs.getString("description")
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void insertAttachmentLink(long transactionId, long attachmentId) {
 | 
			
		||||
        DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
| 
						 | 
				
			
			@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
			
		|||
                List.of(transactionId, attachmentId)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long getTagId(String name) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT id FROM transaction_tag WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                rs -> rs.getLong(1)
 | 
			
		||||
        ).orElse(-1L);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void removeTag(long transactionId, String tag) {
 | 
			
		||||
        long id = getTagId(tag);
 | 
			
		||||
        if (id != -1) {
 | 
			
		||||
            DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void addTag(long transactionId, String tag) {
 | 
			
		||||
        long id = getOrCreateTagId(tag);
 | 
			
		||||
        boolean exists = DbUtil.count(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
 | 
			
		||||
                transactionId,
 | 
			
		||||
                id
 | 
			
		||||
        ) > 0;
 | 
			
		||||
        if (!exists) {
 | 
			
		||||
            DbUtil.insertOne(
 | 
			
		||||
                    conn,
 | 
			
		||||
                    "INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
 | 
			
		||||
                    transactionId,
 | 
			
		||||
                    id
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Transaction parseTransaction(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new Transaction(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
			
		||||
                rs.getBigDecimal("amount"),
 | 
			
		||||
                Currency.getInstance(rs.getString("currency")),
 | 
			
		||||
                rs.getString("description"),
 | 
			
		||||
                rs.getObject("vendor_id", Long.class),
 | 
			
		||||
                rs.getObject("category_id", Long.class)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,78 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionVendor> findById(long id) {
 | 
			
		||||
        return DbUtil.findById(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor WHERE id = ?",
 | 
			
		||||
                id,
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionVendor> findByName(String name) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionVendor> findAll() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor ORDER BY name ASC",
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name, String description) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
 | 
			
		||||
                List.of(name, description)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_vendor (name) VALUES (?)",
 | 
			
		||||
                List.of(name)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteById(long id) {
 | 
			
		||||
        DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new TransactionVendor(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                rs.getString("name"),
 | 
			
		||||
                rs.getString("description")
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.util;
 | 
			
		||||
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
public class ColorUtil {
 | 
			
		||||
    public static String toHex(Color color) {
 | 
			
		||||
        return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static String formatColorDouble(double val) {
 | 
			
		||||
        String in = Integer.toHexString((int) Math.round(val * 255));
 | 
			
		||||
        return in.length() == 1 ? "0" + in : in;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +58,17 @@ public final class DbUtil {
 | 
			
		|||
        return findAll(conn, query, pagination, Collections.emptyList(), mapper);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long count(Connection conn, String query, Object... args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
            var rs = stmt.executeQuery();
 | 
			
		||||
            if (!rs.next()) throw new UncheckedSqlException("No count result available.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new UncheckedSqlException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
| 
						 | 
				
			
			@ -82,6 +93,10 @@ public final class DbUtil {
 | 
			
		|||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static int update(Connection conn, String query, Object... args) {
 | 
			
		||||
        return update(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void updateOne(Connection conn, String query, List<Object> args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
| 
						 | 
				
			
			@ -92,19 +107,27 @@ public final class DbUtil {
 | 
			
		|||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void updateOne(Connection conn, String query, Object... args) {
 | 
			
		||||
        updateOne(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long insertOne(Connection conn, String query, List<Object> args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
            int result = stmt.executeUpdate();
 | 
			
		||||
            if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
 | 
			
		||||
            var rs = stmt.getGeneratedKeys();
 | 
			
		||||
            rs.next();
 | 
			
		||||
            if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new UncheckedSqlException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long insertOne(Connection conn, String query, Object... args) {
 | 
			
		||||
        return insertOne(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
 | 
			
		||||
        return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
 | 
			
		|||
    private final BigDecimal amount;
 | 
			
		||||
    private final Currency currency;
 | 
			
		||||
    private final String description;
 | 
			
		||||
    private final Long vendorId;
 | 
			
		||||
    private final Long categoryId;
 | 
			
		||||
 | 
			
		||||
    public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
 | 
			
		||||
    public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.timestamp = timestamp;
 | 
			
		||||
        this.amount = amount;
 | 
			
		||||
        this.currency = currency;
 | 
			
		||||
        this.description = description;
 | 
			
		||||
        this.vendorId = vendorId;
 | 
			
		||||
        this.categoryId = categoryId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public LocalDateTime getTimestamp() {
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
 | 
			
		|||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Long getVendorId() {
 | 
			
		||||
        return vendorId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Long getCategoryId() {
 | 
			
		||||
        return categoryId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public MoneyValue getMoneyAmount() {
 | 
			
		||||
        return new MoneyValue(amount, currency);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
public class TransactionCategory extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 63;
 | 
			
		||||
 | 
			
		||||
    private final Long parentId;
 | 
			
		||||
    private final String name;
 | 
			
		||||
    private final Color color;
 | 
			
		||||
 | 
			
		||||
    public TransactionCategory(long id, Long parentId, String name, Color color) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.parentId = parentId;
 | 
			
		||||
        this.name = name;
 | 
			
		||||
        this.color = color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Long getParentId() {
 | 
			
		||||
        return parentId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Color getColor() {
 | 
			
		||||
        return color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A line item that comprises part of a transaction. Its total value (value per
 | 
			
		||||
 * item * quantity) is part of the transaction's total value. It can be used to
 | 
			
		||||
 * record some transactions, like purchases and invoices, in more granular
 | 
			
		||||
 * detail.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionLineItem extends IdEntity {
 | 
			
		||||
    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
			
		||||
 | 
			
		||||
    private final long transactionId;
 | 
			
		||||
    private final BigDecimal valuePerItem;
 | 
			
		||||
    private final int quantity;
 | 
			
		||||
    private final int idx;
 | 
			
		||||
    private final String description;
 | 
			
		||||
 | 
			
		||||
    public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.transactionId = transactionId;
 | 
			
		||||
        this.valuePerItem = valuePerItem;
 | 
			
		||||
        this.quantity = quantity;
 | 
			
		||||
        this.idx = idx;
 | 
			
		||||
        this.description = description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public long getTransactionId() {
 | 
			
		||||
        return transactionId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BigDecimal getValuePerItem() {
 | 
			
		||||
        return valuePerItem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public int getQuantity() {
 | 
			
		||||
        return quantity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public int getIdx() {
 | 
			
		||||
        return idx;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getDescription() {
 | 
			
		||||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BigDecimal getTotalValue() {
 | 
			
		||||
        return valuePerItem.multiply(new BigDecimal(quantity));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return String.format(
 | 
			
		||||
                "TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
 | 
			
		||||
                id,
 | 
			
		||||
                transactionId,
 | 
			
		||||
                valuePerItem.toPlainString(),
 | 
			
		||||
                quantity,
 | 
			
		||||
                idx,
 | 
			
		||||
                description
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A tag that can be applied to a transaction to add some user-defined semantic
 | 
			
		||||
 * meaning to it.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionTag extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 63;
 | 
			
		||||
    private final String name;
 | 
			
		||||
 | 
			
		||||
    public TransactionTag(long id, String name) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.name = name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A vendor is a business establishment that can be linked to a transaction, to
 | 
			
		||||
 * denote the business that the transaction took place with.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionVendor extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 255;
 | 
			
		||||
    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
			
		||||
 | 
			
		||||
    private final String name;
 | 
			
		||||
    private final String description;
 | 
			
		||||
 | 
			
		||||
    public TransactionVendor(long id, String name, String description) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.name = name;
 | 
			
		||||
        this.description = description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getDescription() {
 | 
			
		||||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -18,12 +18,14 @@ import java.util.function.Consumer;
 | 
			
		|||
 */
 | 
			
		||||
public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
			
		||||
    private final List<ThrowableConsumer<Consumer<String>>> tasks;
 | 
			
		||||
    private final boolean delayTasks;
 | 
			
		||||
    private boolean startupSuccessful = false;
 | 
			
		||||
 | 
			
		||||
    private final TextArea textArea = new TextArea();
 | 
			
		||||
 | 
			
		||||
    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
 | 
			
		||||
    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
 | 
			
		||||
        this.tasks = tasks;
 | 
			
		||||
        this.delayTasks = delayTasks;
 | 
			
		||||
        setTitle("Starting Perfin...");
 | 
			
		||||
        setResizable(false);
 | 
			
		||||
        initStyle(StageStyle.UNDECORATED);
 | 
			
		||||
| 
						 | 
				
			
			@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
			
		|||
     */
 | 
			
		||||
    private void runTasks() {
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            try {
 | 
			
		||||
                Thread.sleep(1000);
 | 
			
		||||
            } catch (InterruptedException e) {
 | 
			
		||||
                throw new RuntimeException(e);
 | 
			
		||||
            }
 | 
			
		||||
            if (delayTasks) sleepOrThrowRE(1000);
 | 
			
		||||
            for (var task : tasks) {
 | 
			
		||||
                try {
 | 
			
		||||
                    CompletableFuture<Void> future = new CompletableFuture<>();
 | 
			
		||||
| 
						 | 
				
			
			@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
			
		|||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
                    future.join();
 | 
			
		||||
                    Thread.sleep(500);
 | 
			
		||||
                    if (delayTasks) sleepOrThrowRE(500);
 | 
			
		||||
                } catch (Exception e) {
 | 
			
		||||
                    accept("Startup failed: " + e.getMessage());
 | 
			
		||||
                    e.printStackTrace(System.err);
 | 
			
		||||
                    try {
 | 
			
		||||
                        Thread.sleep(5000);
 | 
			
		||||
                    } catch (InterruptedException ex) {
 | 
			
		||||
                        throw new RuntimeException(ex);
 | 
			
		||||
                    }
 | 
			
		||||
                    sleepOrThrowRE(5000);
 | 
			
		||||
                    Platform.runLater(this::close);
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            accept("Startup successful!");
 | 
			
		||||
            try {
 | 
			
		||||
                Thread.sleep(1000);
 | 
			
		||||
            } catch (InterruptedException e) {
 | 
			
		||||
                throw new RuntimeException(e);
 | 
			
		||||
            }
 | 
			
		||||
            if (delayTasks) sleepOrThrowRE(1000);
 | 
			
		||||
            startupSuccessful = true;
 | 
			
		||||
            Platform.runLater(this::close);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Helper method to sleep the current thread or throw a runtime exception.
 | 
			
		||||
     * @param ms The number of milliseconds to sleep for.
 | 
			
		||||
     */
 | 
			
		||||
    private static void sleepOrThrowRE(long ms) {
 | 
			
		||||
        try {
 | 
			
		||||
            Thread.sleep(ms);
 | 
			
		||||
        } catch (InterruptedException e) {
 | 
			
		||||
            throw new RuntimeException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -43,15 +43,39 @@
 | 
			
		|||
 | 
			
		||||
                <!-- Container for linked accounts -->
 | 
			
		||||
                <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                    <VBox HBox.hgrow="ALWAYS">
 | 
			
		||||
                        <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
 | 
			
		||||
                        <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                    <VBox HBox.hgrow="ALWAYS">
 | 
			
		||||
                        <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
 | 
			
		||||
                        <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                </HBox>
 | 
			
		||||
 | 
			
		||||
                <!-- Additional, mostly optional properties -->
 | 
			
		||||
                <PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
 | 
			
		||||
                    <columnConstraints>
 | 
			
		||||
                        <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
			
		||||
                        <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
			
		||||
                    </columnConstraints>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                    <ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                    <ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                    <VBox maxWidth="Infinity">
 | 
			
		||||
                        <HBox styleClass="std-spacing">
 | 
			
		||||
                            <ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
 | 
			
		||||
                            <Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
 | 
			
		||||
                        </HBox>
 | 
			
		||||
                        <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <!-- Container for attachments -->
 | 
			
		||||
                <VBox styleClass="std-padding">
 | 
			
		||||
                    <Label text="Attachments" styleClass="bold-text"/>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue