Finished transaction editing logic.
This commit is contained in:
parent
f0b061c34d
commit
47ac75af45
|
@ -5,7 +5,6 @@ import com.andrewlalis.perfin.data.AccountRepository;
|
|||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
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.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
|
@ -32,7 +31,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
@FXML public TextField timestampField;
|
||||
@FXML public TextField balanceField;
|
||||
@FXML public Label balanceWarningLabel;
|
||||
private FileSelectionArea attachmentSelectionArea;
|
||||
@FXML public FileSelectionArea attachmentSelectionArea;
|
||||
@FXML public PropertiesPane propertiesPane;
|
||||
|
||||
@FXML public Button saveButton;
|
||||
|
@ -71,14 +70,6 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
|
||||
var formValid = timestampValid.and(balanceValid);
|
||||
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
|
||||
|
@ -110,7 +101,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
account.id,
|
||||
reportedBalance,
|
||||
account.getCurrency(),
|
||||
attachmentSelectionArea.getSelectedFiles()
|
||||
attachmentSelectionArea.getSelectedPaths()
|
||||
);
|
||||
});
|
||||
router.navigateBackAndClear();
|
||||
|
|
|
@ -6,10 +6,7 @@ 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.data.util.FileUtil;
|
||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
|
@ -20,8 +17,8 @@ import javafx.beans.property.Property;
|
|||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
@ -30,6 +27,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;
|
||||
|
@ -39,6 +37,7 @@ import static com.andrewlalis.perfin.PerfinApp.router;
|
|||
public class EditTransactionController implements RouteSelectionListener {
|
||||
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
|
||||
|
||||
@FXML public BorderPane container;
|
||||
@FXML public Label titleLabel;
|
||||
|
||||
@FXML public TextField timestampField;
|
||||
|
@ -50,8 +49,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@FXML public AccountSelectionBox debitAccountSelector;
|
||||
@FXML public AccountSelectionBox creditAccountSelector;
|
||||
|
||||
@FXML public VBox attachmentsVBox;
|
||||
private FileSelectionArea attachmentsSelectionArea;
|
||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||
|
||||
@FXML public Button saveButton;
|
||||
|
||||
|
@ -85,45 +83,62 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
)
|
||||
.addPredicate(
|
||||
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()))
|
||||
),
|
||||
"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 formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||
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() {
|
||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||
Currency currency = currencyChoiceBox.getValue();
|
||||
String description = getSanitizedDescription();
|
||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
||||
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
||||
final long idToNavigate;
|
||||
if (transaction == null) {
|
||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||
Currency currency = currencyChoiceBox.getValue();
|
||||
String description = getSanitizedDescription();
|
||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||
List<Path> attachments = attachmentsSelectionArea.getSelectedFiles();
|
||||
Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> {
|
||||
repo.insert(
|
||||
idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.insert(
|
||||
utcTimestamp,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
linkedAccounts,
|
||||
newAttachmentPaths
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Profile.getCurrent().getDataSource().useRepo(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.update(
|
||||
transaction.id,
|
||||
utcTimestamp,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
linkedAccounts,
|
||||
attachments
|
||||
);
|
||||
});
|
||||
existingAttachments,
|
||||
newAttachmentPaths
|
||||
)
|
||||
);
|
||||
idToNavigate = transaction.id;
|
||||
}
|
||||
router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate));
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
|
@ -133,57 +148,58 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
transaction = (Transaction) context;
|
||||
boolean creatingNew = transaction == null;
|
||||
|
||||
if (creatingNew) {
|
||||
if (transaction == null) {
|
||||
titleLabel.setText("Create New Transaction");
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
amountField.setText(null);
|
||||
descriptionField.setText(null);
|
||||
attachmentsSelectionArea.clear();
|
||||
} else {
|
||||
titleLabel.setText("Edit Transaction #" + transaction.id);
|
||||
timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp()));
|
||||
amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount()));
|
||||
descriptionField.setText(transaction.getDescription());
|
||||
|
||||
// TODO: Add an editable list of attachments from which some can be added and removed.
|
||||
|
||||
}
|
||||
|
||||
// Fetch some account-specific data.
|
||||
currencyChoiceBox.setDisable(true);
|
||||
creditAccountSelector.setDisable(true);
|
||||
debitAccountSelector.setDisable(true);
|
||||
container.setDisable(true);
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (
|
||||
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
|
||||
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))
|
||||
.toList();
|
||||
var accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
CreditAndDebitAccounts linkedAccounts = transaction == null ? null : transactionRepo.findLinkedAccounts(transaction.id);
|
||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
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(() -> {
|
||||
creditAccountSelector.setAccounts(accounts);
|
||||
debitAccountSelector.setAccounts(accounts);
|
||||
currencyChoiceBox.getItems().setAll(currencies);
|
||||
if (creatingNew) {
|
||||
attachmentsSelectionArea.clear();
|
||||
attachmentsSelectionArea.addAttachments(attachments);
|
||||
if (transaction == null) {
|
||||
// TODO: Allow user to select a default currency.
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
creditAccountSelector.select(null);
|
||||
debitAccountSelector.select(null);
|
||||
} else {
|
||||
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
|
||||
if (linkedAccounts != null) {
|
||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||
}
|
||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||
}
|
||||
currencyChoiceBox.setDisable(false);
|
||||
creditAccountSelector.setDisable(false);
|
||||
debitAccountSelector.setDisable(false);
|
||||
container.setDisable(false);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get repositories.", e);
|
||||
|
|
|
@ -49,14 +49,18 @@ public class TransactionViewController {
|
|||
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
|
||||
List<Attachment> attachments = repo.findAttachments(transaction.id);
|
||||
Platform.runLater(() -> {
|
||||
accounts.ifDebit(acc -> {
|
||||
debitAccountLink.setText(acc.getShortName());
|
||||
debitAccountLink.setOnAction(event -> router.navigate("account", acc));
|
||||
});
|
||||
accounts.ifCredit(acc -> {
|
||||
creditAccountLink.setText(acc.getShortName());
|
||||
creditAccountLink.setOnAction(event -> router.navigate("account", acc));
|
||||
});
|
||||
if (accounts.hasDebit()) {
|
||||
debitAccountLink.setText(accounts.debitAccount().getShortName());
|
||||
debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
|
||||
} else {
|
||||
debitAccountLink.setText(null);
|
||||
}
|
||||
if (accounts.hasCredit()) {
|
||||
creditAccountLink.setText(accounts.creditAccount().getShortName());
|
||||
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
|
||||
} else {
|
||||
creditAccountLink.setText(null);
|
||||
}
|
||||
attachmentsViewPane.setAttachments(attachments);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -126,12 +126,9 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
// Refresh account filter options.
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
accounts.add(null);
|
||||
Platform.runLater(() -> {
|
||||
filterByAccountComboBox.getItems().clear();
|
||||
filterByAccountComboBox.getItems().addAll(accounts);
|
||||
filterByAccountComboBox.getSelectionModel().selectLast();
|
||||
filterByAccountComboBox.getButtonCell().updateIndex(accounts.size() - 1);
|
||||
filterByAccountComboBox.setAccounts(accounts);
|
||||
filterByAccountComboBox.select(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
public interface Repository {
|
||||
}
|
||||
/**
|
||||
* Marker interface used to identify any data repository.
|
||||
*/
|
||||
public interface Repository {}
|
||||
|
|
|
@ -32,4 +32,14 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
|||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||
List<Attachment> findAttachments(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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -60,8 +60,8 @@ public record JdbcAttachmentRepository(Connection conn, Path contentDir) impleme
|
|||
public void deleteById(long attachmentId) {
|
||||
var optionalAttachment = findById(attachmentId);
|
||||
if (optionalAttachment.isPresent()) {
|
||||
deleteFileOnDisk(optionalAttachment.get());
|
||||
DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId));
|
||||
deleteFileOnDisk(optionalAttachment.get());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,10 +5,13 @@ import com.andrewlalis.perfin.data.AttachmentRepository;
|
|||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
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.model.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
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));
|
||||
// 3. Add attachments.
|
||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
|
||||
for (var attachmentPath : attachments) {
|
||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||
// Insert the link-table entry.
|
||||
DbUtil.setArgs(stmt, txId, attachment.id);
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
for (Path attachmentPath : attachments) {
|
||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||
insertAttachmentLink(txId, attachment.id);
|
||||
}
|
||||
return txId;
|
||||
});
|
||||
|
@ -150,10 +149,78 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
|
||||
@Override
|
||||
public void delete(long transactionId) {
|
||||
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
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();
|
||||
}
|
||||
|
||||
@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
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
|
@ -168,4 +235,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
@ -85,4 +86,21 @@ public class FileUtil {
|
|||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ public class AttachmentsViewPane extends VBox {
|
|||
}
|
||||
|
||||
public void setAttachments(List<Attachment> attachments) {
|
||||
this.attachments.clear();
|
||||
this.attachments.setAll(attachments);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
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 javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
|
@ -17,33 +16,55 @@ import javafx.stage.Window;
|
|||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A pane within which a user can select one or more files.
|
||||
*/
|
||||
public class FileSelectionArea extends VBox {
|
||||
public final BooleanProperty allowMultiple = new SimpleBooleanProperty(false);
|
||||
private final ObservableList<Path> selectedFiles = FXCollections.observableArrayList();
|
||||
interface FileItem {
|
||||
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");
|
||||
|
||||
VBox filesVBox = new VBox();
|
||||
filesVBox.getStyleClass().addAll("std-padding", "std-spacing");
|
||||
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.");
|
||||
noFilesLabel.managedProperty().bind(noFilesLabel.visibleProperty());
|
||||
noFilesLabel.visibleProperty().bind(selectedFilesProperty.emptyProperty());
|
||||
|
||||
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(
|
||||
allowMultiple.not().and(selectedFilesProperty.emptyProperty().not())
|
||||
.or(fileChooserProperty.isNull())
|
||||
);
|
||||
|
||||
getChildren().addAll(
|
||||
|
@ -53,23 +74,48 @@ public class FileSelectionArea extends VBox {
|
|||
);
|
||||
}
|
||||
|
||||
public List<Path> getSelectedFiles() {
|
||||
return Collections.unmodifiableList(selectedFiles);
|
||||
public List<Attachment> getSelectedAttachments() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
|
||||
private Node buildFileItem(Path path) {
|
||||
Label filenameLabel = new Label(path.getFileName().toString());
|
||||
private Node buildFileItem(FileItem item) {
|
||||
Label filenameLabel = new Label(item.getName());
|
||||
filenameLabel.getStyleClass().addAll("mono-font");
|
||||
Button removeButton = new Button("Remove");
|
||||
removeButton.setOnAction(event -> selectedFiles.remove(path));
|
||||
removeButton.setOnAction(event -> selectedFiles.remove(item));
|
||||
AnchorPane pane = new AnchorPane(filenameLabel, removeButton);
|
||||
AnchorPane.setLeftAnchor(filenameLabel, 0.0);
|
||||
AnchorPane.setTopAnchor(filenameLabel, 0.0);
|
||||
|
@ -84,17 +130,35 @@ public class FileSelectionArea extends VBox {
|
|||
var files = fileChooser.showOpenMultipleDialog(owner);
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
Path path = file.toPath();
|
||||
if (!selectedFiles.contains(path)) {
|
||||
selectedFiles.add(path);
|
||||
FileItem item = new PathItem(file.toPath());
|
||||
if (!selectedFiles.contains(item)) {
|
||||
selectedFiles.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
File file = fileChooser.showOpenDialog(owner);
|
||||
if (file != null && !selectedFiles.contains(file.toPath())) {
|
||||
selectedFiles.add(file.toPath());
|
||||
FileItem item = file == null ? null : new PathItem(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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
|
||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
|
@ -50,6 +51,7 @@
|
|||
|
||||
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
<FileSelectionArea fx:id="attachmentSelectionArea" allowMultiple="true"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<Separator/>
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
|
||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
||||
fx:id="container"
|
||||
>
|
||||
<top>
|
||||
<Label fx:id="titleLabel" styleClass="large-font,bold-text,std-padding"/>
|
||||
|
@ -51,10 +53,10 @@
|
|||
</VBox>
|
||||
</HBox>
|
||||
<!-- Container for attachments -->
|
||||
<VBox fx:id="attachmentsVBox" styleClass="std-padding">
|
||||
<VBox styleClass="std-padding">
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
<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>
|
||||
|
||||
<!-- Buttons -->
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<HBox styleClass="std-padding,std-spacing">
|
||||
<PropertiesPane hgap="5" vgap="5">
|
||||
<Label text="Filter by Account"/>
|
||||
<AccountSelectionBox fx:id="filterByAccountComboBox"/>
|
||||
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
||||
</PropertiesPane>
|
||||
</HBox>
|
||||
</top>
|
||||
|
|
Loading…
Reference in New Issue