Finished transaction editing logic.

This commit is contained in:
Andrew Lalis 2024-01-13 12:54:59 -05:00
parent f0b061c34d
commit 47ac75af45
14 changed files with 289 additions and 107 deletions

View File

@ -5,7 +5,6 @@ import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
@ -32,7 +31,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
@FXML public TextField timestampField; @FXML public TextField timestampField;
@FXML public TextField balanceField; @FXML public TextField balanceField;
@FXML public Label balanceWarningLabel; @FXML public Label balanceWarningLabel;
private FileSelectionArea attachmentSelectionArea; @FXML public FileSelectionArea attachmentSelectionArea;
@FXML public PropertiesPane propertiesPane; @FXML public PropertiesPane propertiesPane;
@FXML public Button saveButton; @FXML public Button saveButton;
@ -71,14 +70,6 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
var formValid = timestampValid.and(balanceValid); var formValid = timestampValid.and(balanceValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
// Manually append the attachment selection area to the end of the properties pane.
attachmentSelectionArea = new FileSelectionArea(
FileUtil::newAttachmentsFileChooser,
() -> timestampField.getScene().getWindow()
);
attachmentSelectionArea.allowMultiple.set(true);
propertiesPane.getChildren().addLast(attachmentSelectionArea);
} }
@Override @Override
@ -110,7 +101,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
account.id, account.id,
reportedBalance, reportedBalance,
account.getCurrency(), account.getCurrency(),
attachmentSelectionArea.getSelectedFiles() attachmentSelectionArea.getSelectedPaths()
); );
}); });
router.navigateBackAndClear(); router.navigateBackAndClear();

View File

@ -6,10 +6,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
@ -20,8 +17,8 @@ import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -30,6 +27,7 @@ import java.nio.file.Path;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.Currency; import java.util.Currency;
import java.util.List; import java.util.List;
@ -39,6 +37,7 @@ import static com.andrewlalis.perfin.PerfinApp.router;
public class EditTransactionController implements RouteSelectionListener { public class EditTransactionController implements RouteSelectionListener {
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class); private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
@FXML public BorderPane container;
@FXML public Label titleLabel; @FXML public Label titleLabel;
@FXML public TextField timestampField; @FXML public TextField timestampField;
@ -50,8 +49,7 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox debitAccountSelector;
@FXML public AccountSelectionBox creditAccountSelector; @FXML public AccountSelectionBox creditAccountSelector;
@FXML public VBox attachmentsVBox; @FXML public FileSelectionArea attachmentsSelectionArea;
private FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton; @FXML public Button saveButton;
@ -85,45 +83,62 @@ public class EditTransactionController implements RouteSelectionListener {
) )
.addPredicate( .addPredicate(
accounts -> ( accounts -> (
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) || (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
), ),
"Linked accounts must use the same currency." "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); ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
// Initialize the file selection area.
attachmentsSelectionArea = new FileSelectionArea(
FileUtil::newAttachmentsFileChooser,
() -> attachmentsVBox.getScene().getWindow()
);
attachmentsSelectionArea.allowMultiple.set(true);
attachmentsVBox.getChildren().add(attachmentsSelectionArea);
} }
@FXML public void save() { @FXML public void save() {
final long idToNavigate;
if (transaction == null) {
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp()); LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText()); BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue(); Currency currency = currencyChoiceBox.getValue();
String description = getSanitizedDescription(); String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
List<Path> attachments = attachmentsSelectionArea.getSelectedFiles(); List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> { List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
repo.insert( final long idToNavigate;
if (transaction == null) {
idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
TransactionRepository.class,
repo -> repo.insert(
utcTimestamp, utcTimestamp,
amount, amount,
currency, currency,
description, description,
linkedAccounts, linkedAccounts,
attachments newAttachmentPaths
)
); );
}); } else {
Profile.getCurrent().getDataSource().useRepo(
TransactionRepository.class,
repo -> repo.update(
transaction.id,
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
existingAttachments,
newAttachmentPaths
)
);
idToNavigate = transaction.id;
} }
router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate));
} }
@FXML public void cancel() { @FXML public void cancel() {
@ -133,57 +148,58 @@ public class EditTransactionController implements RouteSelectionListener {
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
transaction = (Transaction) context; transaction = (Transaction) context;
boolean creatingNew = transaction == null;
if (creatingNew) { if (transaction == null) {
titleLabel.setText("Create New Transaction"); titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
amountField.setText(null); amountField.setText(null);
descriptionField.setText(null); descriptionField.setText(null);
attachmentsSelectionArea.clear();
} else { } else {
titleLabel.setText("Edit Transaction #" + transaction.id); titleLabel.setText("Edit Transaction #" + transaction.id);
timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp())); timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp()));
amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount())); amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount()));
descriptionField.setText(transaction.getDescription()); descriptionField.setText(transaction.getDescription());
// TODO: Add an editable list of attachments from which some can be added and removed.
} }
// Fetch some account-specific data. // Fetch some account-specific data.
currencyChoiceBox.setDisable(true); container.setDisable(true);
creditAccountSelector.setDisable(true);
debitAccountSelector.setDisable(true);
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try ( try (
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository() var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository()
) { ) {
var currencies = accountRepo.findAllUsedCurrencies().stream() // First fetch all the data.
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
.sorted(Comparator.comparing(Currency::getCurrencyCode)) .sorted(Comparator.comparing(Currency::getCurrencyCode))
.toList(); .toList();
var accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
CreditAndDebitAccounts linkedAccounts = transaction == null ? null : transactionRepo.findLinkedAccounts(transaction.id); final List<Attachment> attachments;
final CreditAndDebitAccounts linkedAccounts;
if (transaction == null) {
attachments = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null);
} else {
attachments = transactionRepo.findAttachments(transaction.id);
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
}
// Then make updates to the view.
Platform.runLater(() -> { Platform.runLater(() -> {
creditAccountSelector.setAccounts(accounts); creditAccountSelector.setAccounts(accounts);
debitAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts);
currencyChoiceBox.getItems().setAll(currencies); currencyChoiceBox.getItems().setAll(currencies);
if (creatingNew) { attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments);
if (transaction == null) {
// TODO: Allow user to select a default currency. // TODO: Allow user to select a default currency.
currencyChoiceBox.getSelectionModel().selectFirst(); currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null); creditAccountSelector.select(null);
debitAccountSelector.select(null); debitAccountSelector.select(null);
} else { } else {
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
if (linkedAccounts != null) {
creditAccountSelector.select(linkedAccounts.creditAccount()); creditAccountSelector.select(linkedAccounts.creditAccount());
debitAccountSelector.select(linkedAccounts.debitAccount()); debitAccountSelector.select(linkedAccounts.debitAccount());
} }
} container.setDisable(false);
currencyChoiceBox.setDisable(false);
creditAccountSelector.setDisable(false);
debitAccountSelector.setDisable(false);
}); });
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to get repositories.", e); log.error("Failed to get repositories.", e);

View File

@ -49,14 +49,18 @@ public class TransactionViewController {
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
List<Attachment> attachments = repo.findAttachments(transaction.id); List<Attachment> attachments = repo.findAttachments(transaction.id);
Platform.runLater(() -> { Platform.runLater(() -> {
accounts.ifDebit(acc -> { if (accounts.hasDebit()) {
debitAccountLink.setText(acc.getShortName()); debitAccountLink.setText(accounts.debitAccount().getShortName());
debitAccountLink.setOnAction(event -> router.navigate("account", acc)); debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
}); } else {
accounts.ifCredit(acc -> { debitAccountLink.setText(null);
creditAccountLink.setText(acc.getShortName()); }
creditAccountLink.setOnAction(event -> router.navigate("account", acc)); if (accounts.hasCredit()) {
}); creditAccountLink.setText(accounts.creditAccount().getShortName());
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
} else {
creditAccountLink.setText(null);
}
attachmentsViewPane.setAttachments(attachments); attachmentsViewPane.setAttachments(attachments);
}); });
}); });

View File

@ -126,12 +126,9 @@ public class TransactionsViewController implements RouteSelectionListener {
// Refresh account filter options. // Refresh account filter options.
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
accounts.add(null);
Platform.runLater(() -> { Platform.runLater(() -> {
filterByAccountComboBox.getItems().clear(); filterByAccountComboBox.setAccounts(accounts);
filterByAccountComboBox.getItems().addAll(accounts); filterByAccountComboBox.select(null);
filterByAccountComboBox.getSelectionModel().selectLast();
filterByAccountComboBox.getButtonCell().updateIndex(accounts.size() - 1);
}); });
}); });

View File

@ -1,4 +1,6 @@
package com.andrewlalis.perfin.data; package com.andrewlalis.perfin.data;
public interface Repository { /**
} * Marker interface used to identify any data repository.
*/
public interface Repository {}

View File

@ -32,4 +32,14 @@ public interface TransactionRepository extends Repository, AutoCloseable {
CreditAndDebitAccounts findLinkedAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId); List<Attachment> findAttachments(long transactionId);
void delete(long transactionId); void delete(long transactionId);
void update(
long id,
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
);
} }

View File

@ -60,8 +60,8 @@ public record JdbcAttachmentRepository(Connection conn, Path contentDir) impleme
public void deleteById(long attachmentId) { public void deleteById(long attachmentId) {
var optionalAttachment = findById(attachmentId); var optionalAttachment = findById(attachmentId);
if (optionalAttachment.isPresent()) { if (optionalAttachment.isPresent()) {
deleteFileOnDisk(optionalAttachment.get());
DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId)); DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId));
deleteFileOnDisk(optionalAttachment.get());
} }
} }

View File

@ -5,10 +5,13 @@ import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
@ -40,13 +43,9 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments. // 3. Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) { for (Path attachmentPath : attachments) {
for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath); Attachment attachment = attachmentRepo.insert(attachmentPath);
// Insert the link-table entry. insertAttachmentLink(txId, attachment.id);
DbUtil.setArgs(stmt, txId, attachment.id);
stmt.executeUpdate();
}
} }
return txId; return txId;
}); });
@ -150,10 +149,78 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
@Override @Override
public void delete(long transactionId) { public void delete(long transactionId) {
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId)); DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(transactionId));
});
new JdbcAttachmentRepository(conn, contentDir).deleteAllOrphans(); new JdbcAttachmentRepository(conn, contentDir).deleteAllOrphans();
} }
@Override
public void update(
long id,
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
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);
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));
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));
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));
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));
updateMessages.add("Updated description.");
}
boolean updateAccountEntries = !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));
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 attachments changes.
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
removedAttachments.removeAll(existingAttachments);
for (Attachment removedAttachment : removedAttachments) {
attachmentRepo.deleteById(removedAttachment.id);
updateMessages.add("Removed attachment \"" + removedAttachment.getFilename() + "\".");
}
for (Path attachmentPath : newAttachmentPaths) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
}
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));
linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
});
}
@Override @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();
@ -168,4 +235,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
rs.getString("description") rs.getString("description")
); );
} }
private void insertAttachmentLink(long transactionId, long attachmentId) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
List.of(transactionId, attachmentId)
);
}
} }

View File

@ -10,6 +10,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor; import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -85,4 +86,21 @@ public class FileUtil {
); );
return fileChooser; return fileChooser;
} }
public static byte[] getHash(Path path) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[4096];
try (var in = Files.newInputStream(path)) {
int count = in.read(buffer);
while (count != -1) {
md.update(buffer, 0, count);
count = in.read(buffer);
}
}
return md.digest();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
} }

View File

@ -50,6 +50,7 @@ public class AttachmentsViewPane extends VBox {
} }
public void setAttachments(List<Attachment> attachments) { public void setAttachments(List<Attachment> attachments) {
this.attachments.clear();
this.attachments.setAll(attachments); this.attachments.setAll(attachments);
} }

View File

@ -1,10 +1,9 @@
package com.andrewlalis.perfin.view.component; package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.BindingUtil;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.*;
import javafx.beans.property.ListProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.scene.Node; import javafx.scene.Node;
@ -17,33 +16,55 @@ import javafx.stage.Window;
import java.io.File; import java.io.File;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collections; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Supplier;
/** /**
* A pane within which a user can select one or more files. * A pane within which a user can select one or more files.
*/ */
public class FileSelectionArea extends VBox { public class FileSelectionArea extends VBox {
public final BooleanProperty allowMultiple = new SimpleBooleanProperty(false); interface FileItem {
private final ObservableList<Path> selectedFiles = FXCollections.observableArrayList(); String getName();
}
public FileSelectionArea(Supplier<FileChooser> fileChooserSupplier, Supplier<Window> windowSupplier) { public record PathItem(Path path) implements FileItem {
@Override
public String getName() {
return path.getFileName().toString();
}
}
public record AttachmentItem(Attachment attachment) implements FileItem {
@Override
public String getName() {
return attachment.getFilename();
}
}
private final BooleanProperty allowMultiple = new SimpleBooleanProperty(false);
private final ObservableList<FileItem> selectedFiles = FXCollections.observableArrayList();
private final ObjectProperty<FileChooser> fileChooserProperty = new SimpleObjectProperty<>(getDefaultFileChooser());
public FileSelectionArea() {
getStyleClass().addAll("std-padding", "std-spacing"); getStyleClass().addAll("std-padding", "std-spacing");
VBox filesVBox = new VBox(); VBox filesVBox = new VBox();
filesVBox.getStyleClass().addAll("std-padding", "std-spacing"); filesVBox.getStyleClass().addAll("std-padding", "std-spacing");
BindingUtil.mapContent(filesVBox.getChildren(), selectedFiles, this::buildFileItem); BindingUtil.mapContent(filesVBox.getChildren(), selectedFiles, this::buildFileItem);
ListProperty<Path> selectedFilesProperty = new SimpleListProperty<>(selectedFiles); ListProperty<FileItem> selectedFilesProperty = new SimpleListProperty<>(selectedFiles);
Label noFilesLabel = new Label("No files selected."); Label noFilesLabel = new Label("No files selected.");
noFilesLabel.managedProperty().bind(noFilesLabel.visibleProperty()); noFilesLabel.managedProperty().bind(noFilesLabel.visibleProperty());
noFilesLabel.visibleProperty().bind(selectedFilesProperty.emptyProperty()); noFilesLabel.visibleProperty().bind(selectedFilesProperty.emptyProperty());
Button selectFilesButton = new Button("Select files"); Button selectFilesButton = new Button("Select files");
selectFilesButton.setOnAction(event -> onSelectFileClicked(fileChooserSupplier.get(), windowSupplier.get())); selectFilesButton.setOnAction(event -> {
onSelectFileClicked(fileChooserProperty.get(), getScene().getWindow());
});
selectFilesButton.disableProperty().bind( selectFilesButton.disableProperty().bind(
allowMultiple.not().and(selectedFilesProperty.emptyProperty().not()) allowMultiple.not().and(selectedFilesProperty.emptyProperty().not())
.or(fileChooserProperty.isNull())
); );
getChildren().addAll( getChildren().addAll(
@ -53,23 +74,48 @@ public class FileSelectionArea extends VBox {
); );
} }
public List<Path> getSelectedFiles() { public List<Attachment> getSelectedAttachments() {
return Collections.unmodifiableList(selectedFiles); List<Attachment> attachments = new ArrayList<>();
for (FileItem item : selectedFiles) {
if (item instanceof AttachmentItem a) {
attachments.add(a.attachment());
}
}
return attachments;
}
public List<Path> getSelectedPaths() {
List<Path> paths = new ArrayList<>();
for (FileItem item : selectedFiles) {
if (item instanceof PathItem p) {
paths.add(p.path());
}
}
return paths;
} }
public void clear() { public void clear() {
selectedFiles.clear(); selectedFiles.clear();
} }
public void setSelectedFiles(List<Path> files) { public void addAttachments(List<Attachment> attachments) {
for (Attachment attachment : attachments) {
FileItem item = new AttachmentItem(attachment);
if (!selectedFiles.contains(item)) {
selectedFiles.add(item);
}
}
}
public void setSelectedFiles(List<FileItem> files) {
selectedFiles.setAll(files); selectedFiles.setAll(files);
} }
private Node buildFileItem(Path path) { private Node buildFileItem(FileItem item) {
Label filenameLabel = new Label(path.getFileName().toString()); Label filenameLabel = new Label(item.getName());
filenameLabel.getStyleClass().addAll("mono-font"); filenameLabel.getStyleClass().addAll("mono-font");
Button removeButton = new Button("Remove"); Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> selectedFiles.remove(path)); removeButton.setOnAction(event -> selectedFiles.remove(item));
AnchorPane pane = new AnchorPane(filenameLabel, removeButton); AnchorPane pane = new AnchorPane(filenameLabel, removeButton);
AnchorPane.setLeftAnchor(filenameLabel, 0.0); AnchorPane.setLeftAnchor(filenameLabel, 0.0);
AnchorPane.setTopAnchor(filenameLabel, 0.0); AnchorPane.setTopAnchor(filenameLabel, 0.0);
@ -84,17 +130,35 @@ public class FileSelectionArea extends VBox {
var files = fileChooser.showOpenMultipleDialog(owner); var files = fileChooser.showOpenMultipleDialog(owner);
if (files != null) { if (files != null) {
for (File file : files) { for (File file : files) {
Path path = file.toPath(); FileItem item = new PathItem(file.toPath());
if (!selectedFiles.contains(path)) { if (!selectedFiles.contains(item)) {
selectedFiles.add(path); selectedFiles.add(item);
} }
} }
} }
} else { } else {
File file = fileChooser.showOpenDialog(owner); File file = fileChooser.showOpenDialog(owner);
if (file != null && !selectedFiles.contains(file.toPath())) { FileItem item = file == null ? null : new PathItem(file.toPath());
selectedFiles.add(file.toPath()); if (item != null && !selectedFiles.contains(item)) {
selectedFiles.add(item);
} }
} }
} }
private FileChooser getDefaultFileChooser() {
return FileUtil.newAttachmentsFileChooser();
}
// Property methods.
public final BooleanProperty allowMultipleProperty() {
return allowMultiple;
}
public final boolean getAllowMultiple() {
return allowMultiple.get();
}
public final void setAllowMultiple(boolean value) {
allowMultiple.set(value);
}
} }

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?> <?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
@ -50,6 +51,7 @@
<Label text="Attachments" styleClass="bold-text"/> <Label text="Attachments" styleClass="bold-text"/>
<FileSelectionArea fx:id="attachmentSelectionArea" allowMultiple="true"/>
</PropertiesPane> </PropertiesPane>
<Separator/> <Separator/>

View File

@ -1,12 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?> <?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?> <?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.EditTransactionController" fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
fx:id="container"
> >
<top> <top>
<Label fx:id="titleLabel" styleClass="large-font,bold-text,std-padding"/> <Label fx:id="titleLabel" styleClass="large-font,bold-text,std-padding"/>
@ -51,10 +53,10 @@
</VBox> </VBox>
</HBox> </HBox>
<!-- Container for attachments --> <!-- Container for attachments -->
<VBox fx:id="attachmentsVBox" styleClass="std-padding"> <VBox styleClass="std-padding">
<Label text="Attachments" styleClass="bold-text"/> <Label text="Attachments" styleClass="bold-text"/>
<Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-font" wrapText="true"/> <Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-font" wrapText="true"/>
<!-- FileSelectionArea inserted here! --> <FileSelectionArea fx:id="attachmentsSelectionArea" allowMultiple="true"/>
</VBox> </VBox>
<!-- Buttons --> <!-- Buttons -->

View File

@ -23,7 +23,7 @@
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="std-padding,std-spacing">
<PropertiesPane hgap="5" vgap="5"> <PropertiesPane hgap="5" vgap="5">
<Label text="Filter by Account"/> <Label text="Filter by Account"/>
<AccountSelectionBox fx:id="filterByAccountComboBox"/> <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
</PropertiesPane> </PropertiesPane>
</HBox> </HBox>
</top> </top>