Added transaction attachments ability.
This commit is contained in:
parent
616cac6c18
commit
53bfea2bad
|
@ -2,16 +2,25 @@ package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.data.DateUtil;
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.data.FileUtil;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
|
||||||
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.SimpleListProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.*;
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.layout.AnchorPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.stage.FileChooser;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
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;
|
||||||
|
@ -34,6 +43,10 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
@FXML public ComboBox<Account> linkCreditAccountComboBox;
|
@FXML public ComboBox<Account> linkCreditAccountComboBox;
|
||||||
@FXML public Label linkedAccountsErrorLabel;
|
@FXML public Label linkedAccountsErrorLabel;
|
||||||
|
|
||||||
|
private final ObservableList<File> selectedAttachmentFiles = FXCollections.observableArrayList();
|
||||||
|
@FXML public VBox selectedFilesVBox;
|
||||||
|
@FXML public Label noSelectedFilesLabel;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
// Setup error field validation.
|
// Setup error field validation.
|
||||||
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
|
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
|
||||||
|
@ -67,6 +80,41 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
updateLinkAccountComboBoxes(newValue);
|
updateLinkAccountComboBoxes(newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show the "no files selected" label when the list is empty. And sync the vbox with the selected files.
|
||||||
|
noSelectedFilesLabel.managedProperty().bind(noSelectedFilesLabel.visibleProperty());
|
||||||
|
var filesListProp = new SimpleListProperty<>(selectedAttachmentFiles);
|
||||||
|
noSelectedFilesLabel.visibleProperty().bind(filesListProp.emptyProperty());
|
||||||
|
BindingUtil.mapContent(selectedFilesVBox.getChildren(), selectedAttachmentFiles, file -> {
|
||||||
|
Label filenameLabel = new Label(file.getName());
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setOnAction(event -> {
|
||||||
|
selectedAttachmentFiles.remove(file);
|
||||||
|
});
|
||||||
|
AnchorPane fileBox = new AnchorPane(filenameLabel, removeButton);
|
||||||
|
AnchorPane.setLeftAnchor(filenameLabel, 0.0);
|
||||||
|
AnchorPane.setRightAnchor(removeButton, 0.0);
|
||||||
|
return fileBox;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void selectAttachmentFile() {
|
||||||
|
FileChooser fileChooser = new FileChooser();
|
||||||
|
fileChooser.setTitle("Select Transaction Attachment(s)");
|
||||||
|
fileChooser.getExtensionFilters().addAll(
|
||||||
|
new FileChooser.ExtensionFilter(
|
||||||
|
"Attachment Files",
|
||||||
|
"*.pdf", "*.docx", "*.odt", "*.html", "*.txt", "*.md", "*.xml", "*.json",
|
||||||
|
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.bmp", "*.tiff"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
List<File> files = fileChooser.showOpenMultipleDialog(amountField.getScene().getWindow());
|
||||||
|
if (files == null) return;
|
||||||
|
for (var file : files) {
|
||||||
|
if (selectedAttachmentFiles.stream().noneMatch(f -> !f.equals(file) && f.getName().equals(file.getName()))) {
|
||||||
|
selectedAttachmentFiles.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void save() {
|
@FXML public void save() {
|
||||||
|
@ -86,9 +134,31 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
Currency currency = currencyChoiceBox.getValue();
|
Currency currency = currencyChoiceBox.getValue();
|
||||||
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
||||||
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
|
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
|
||||||
|
List<TransactionAttachment> attachments = selectedAttachmentFiles.stream()
|
||||||
|
.map(file -> {
|
||||||
|
String filename = file.getName();
|
||||||
|
String filetypeSuffix = filename.substring(filename.lastIndexOf('.'));
|
||||||
|
String mimeType = FileUtil.MIMETYPES.get(filetypeSuffix);
|
||||||
|
return new TransactionAttachment(filename, mimeType);
|
||||||
|
}).toList();
|
||||||
Transaction transaction = new Transaction(timestamp, amount, currency, description);
|
Transaction transaction = new Transaction(timestamp, amount, currency, description);
|
||||||
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
|
||||||
repo.insert(transaction, affectedAccounts);
|
long txId = repo.insert(transaction, affectedAccounts);
|
||||||
|
repo.addAttachments(txId, attachments);
|
||||||
|
// Copy the actual attachment files to their new locations.
|
||||||
|
for (var attachment : repo.findAttachments(txId)) {
|
||||||
|
Path filePath = attachment.getPath();
|
||||||
|
Path dirPath = filePath.getParent();
|
||||||
|
Path originalFilePath = selectedAttachmentFiles.stream()
|
||||||
|
.filter(file -> file.getName().equals(attachment.getFilename()))
|
||||||
|
.findFirst().orElseThrow().toPath();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(dirPath);
|
||||||
|
Files.copy(originalFilePath, filePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
router.navigateBackAndClear();
|
router.navigateBackAndClear();
|
||||||
}
|
}
|
||||||
|
@ -107,6 +177,7 @@ public class CreateTransactionController implements RouteSelectionListener {
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
amountField.setText("0");
|
amountField.setText("0");
|
||||||
descriptionField.setText(null);
|
descriptionField.setText(null);
|
||||||
|
selectedAttachmentFiles.clear();
|
||||||
Thread.ofVirtual().start(() -> {
|
Thread.ofVirtual().start(() -> {
|
||||||
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
|
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
|
||||||
var currencies = repo.findAllUsedCurrencies().stream()
|
var currencies = repo.findAllUsedCurrencies().stream()
|
||||||
|
|
|
@ -125,10 +125,10 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void doTransaction(Connection conn, SQLRunnable runnable) {
|
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
|
||||||
try {
|
try {
|
||||||
conn.setAutoCommit(false);
|
conn.setAutoCommit(false);
|
||||||
runnable.run();
|
return supplier.offer();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try {
|
try {
|
||||||
conn.rollback();
|
conn.rollback();
|
||||||
|
@ -147,4 +147,11 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void doTransaction(Connection conn, SQLRunnable runnable) {
|
||||||
|
doTransaction(conn, () -> {
|
||||||
|
runnable.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class FileUtil {
|
||||||
|
public static Map<String, String> MIMETYPES = new HashMap<>();
|
||||||
|
static {
|
||||||
|
MIMETYPES.put(".pdf", "application/pdf");
|
||||||
|
MIMETYPES.put(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
|
MIMETYPES.put(".odt", "application/vnd.oasis.opendocument.text");
|
||||||
|
MIMETYPES.put(".html", "text/html");
|
||||||
|
MIMETYPES.put(".txt", "text/plain");
|
||||||
|
MIMETYPES.put(".md", "text/markdown");
|
||||||
|
MIMETYPES.put(".xml", "application/xml");
|
||||||
|
MIMETYPES.put(".json", "application/json");
|
||||||
|
MIMETYPES.put(".png", "image/png");
|
||||||
|
MIMETYPES.put(".jpg", "image/jpeg");
|
||||||
|
MIMETYPES.put(".jpeg", "image/jpeg");
|
||||||
|
MIMETYPES.put(".gif", "image/gif");
|
||||||
|
MIMETYPES.put(".webp", "image/webp");
|
||||||
|
MIMETYPES.put(".bmp", "image/bmp");
|
||||||
|
MIMETYPES.put(".tiff", "image/tiff");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public interface SQLSupplier<T> {
|
||||||
|
T offer() throws SQLException;
|
||||||
|
}
|
|
@ -5,14 +5,18 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionAttachment;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface TransactionRepository extends AutoCloseable {
|
public interface TransactionRepository extends AutoCloseable {
|
||||||
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
|
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
|
||||||
|
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
|
||||||
Page<Transaction> findAll(PageRequest pagination);
|
Page<Transaction> findAll(PageRequest pagination);
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
|
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
|
||||||
|
List<TransactionAttachment> findAttachments(long transactionId);
|
||||||
void delete(long transactionId);
|
void delete(long transactionId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,23 +7,47 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionAttachment;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
|
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
|
||||||
@Override
|
@Override
|
||||||
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
|
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
|
||||||
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||||
AtomicLong transactionId = new AtomicLong(-1);
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
long txId = insertTransaction(timestamp, transaction);
|
||||||
|
insertAccountEntriesForTransaction(timestamp, txId, transaction, accountsMap);
|
||||||
|
return txId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addAttachments(long transactionId, List<TransactionAttachment> attachments) {
|
||||||
|
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
// First insert the transaction itself, then add account entries, referencing this transaction.
|
for (var attachment : attachments) {
|
||||||
transactionId.set(DbUtil.insertOne(
|
DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_attachment (uploaded_at, transaction_id, filename, content_type) VALUES (?, ?, ?, ?)",
|
||||||
|
List.of(
|
||||||
|
timestamp,
|
||||||
|
transactionId,
|
||||||
|
attachment.getFilename(),
|
||||||
|
attachment.getContentType()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private long insertTransaction(Timestamp timestamp, Transaction transaction) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
||||||
List.of(
|
List.of(
|
||||||
|
@ -32,8 +56,15 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
transaction.getCurrency().getCurrencyCode(),
|
transaction.getCurrency().getCurrencyCode(),
|
||||||
transaction.getDescription()
|
transaction.getDescription()
|
||||||
)
|
)
|
||||||
));
|
);
|
||||||
// Now insert an account entry for each affected account.
|
}
|
||||||
|
|
||||||
|
private void insertAccountEntriesForTransaction(
|
||||||
|
Timestamp timestamp,
|
||||||
|
long txId,
|
||||||
|
Transaction transaction,
|
||||||
|
Map<Long, AccountEntry.Type> accountsMap
|
||||||
|
) throws SQLException {
|
||||||
try (var stmt = conn.prepareStatement(
|
try (var stmt = conn.prepareStatement(
|
||||||
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
|
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
)) {
|
)) {
|
||||||
|
@ -43,7 +74,7 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
DbUtil.setArgs(stmt, List.of(
|
DbUtil.setArgs(stmt, List.of(
|
||||||
timestamp,
|
timestamp,
|
||||||
accountId,
|
accountId,
|
||||||
transactionId.get(),
|
txId,
|
||||||
transaction.getAmount(),
|
transaction.getAmount(),
|
||||||
entryType.name(),
|
entryType.name(),
|
||||||
transaction.getCurrency().getCurrencyCode()
|
transaction.getCurrency().getCurrencyCode()
|
||||||
|
@ -51,8 +82,6 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
stmt.executeUpdate();
|
stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return transactionId.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -98,6 +127,16 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionAttachment> findAttachments(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_attachment WHERE transaction_id = ? ORDER BY filename ASC",
|
||||||
|
List.of(transactionId),
|
||||||
|
JdbcTransactionRepository::parseAttachment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(long transactionId) {
|
public void delete(long transactionId) {
|
||||||
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
|
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
|
||||||
|
@ -117,4 +156,14 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
|
||||||
rs.getString("description")
|
rs.getString("description")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TransactionAttachment parseAttachment(ResultSet rs) throws SQLException {
|
||||||
|
return new TransactionAttachment(
|
||||||
|
rs.getLong("id"),
|
||||||
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("uploaded_at")),
|
||||||
|
rs.getLong("transaction_id"),
|
||||||
|
rs.getString("filename"),
|
||||||
|
rs.getString("content_type")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.DateUtil;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file that's been attached to a transaction as additional context for it,
|
||||||
|
* like a receipt or invoice copy.
|
||||||
|
*/
|
||||||
|
public class TransactionAttachment {
|
||||||
|
private long id;
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
private long transactionId;
|
||||||
|
|
||||||
|
private String filename;
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
public TransactionAttachment(String filename, String contentType) {
|
||||||
|
this.filename = filename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransactionAttachment(long id, LocalDateTime uploadedAt, long transactionId, String filename, String contentType) {
|
||||||
|
this.id = id;
|
||||||
|
this.uploadedAt = uploadedAt;
|
||||||
|
this.transactionId = transactionId;
|
||||||
|
this.filename = filename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUploadedAt() {
|
||||||
|
return uploadedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTransactionId() {
|
||||||
|
return transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getPath() {
|
||||||
|
String uploadDateStr = uploadedAt.format(DateUtil.DEFAULT_DATE_FORMAT);
|
||||||
|
return Profile.getContentDir(Profile.getCurrent().getName())
|
||||||
|
.resolve("transaction-attachments")
|
||||||
|
.resolve(uploadDateStr)
|
||||||
|
.resolve("tx-" + transactionId)
|
||||||
|
.resolve(filename);
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@
|
||||||
<VBox>
|
<VBox>
|
||||||
<HBox>
|
<HBox>
|
||||||
<!-- Main account properties. -->
|
<!-- Main account properties. -->
|
||||||
<VBox HBox.hgrow="SOMETIMES">
|
<VBox HBox.hgrow="SOMETIMES" style="-fx-border-color: blue">
|
||||||
<VBox styleClass="account-property-box">
|
<VBox styleClass="account-property-box">
|
||||||
<Label text="Name"/>
|
<Label text="Name"/>
|
||||||
<TextField fx:id="accountNameField" editable="false"/>
|
<TextField fx:id="accountNameField" editable="false"/>
|
||||||
|
@ -38,6 +38,9 @@
|
||||||
<TextField fx:id="accountBalanceField" editable="false"/>
|
<TextField fx:id="accountBalanceField" editable="false"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
<VBox HBox.hgrow="SOMETIMES" style="-fx-border-color: red">
|
||||||
|
<Label text="Panel 2"/>
|
||||||
|
</VBox>
|
||||||
</HBox>
|
</HBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
</center>
|
</center>
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
<center>
|
<center>
|
||||||
<ScrollPane fitToWidth="true" fitToHeight="true">
|
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||||
<VBox styleClass="form-container">
|
<VBox styleClass="form-container">
|
||||||
|
<!-- Basic properties -->
|
||||||
<VBox>
|
<VBox>
|
||||||
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
|
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
|
||||||
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
<TextArea fx:id="descriptionField" styleClass="mono-font" wrapText="true"/>
|
<TextArea fx:id="descriptionField" styleClass="mono-font" wrapText="true"/>
|
||||||
<Label fx:id="descriptionErrorLabel" styleClass="error-text" wrapText="true"/>
|
<Label fx:id="descriptionErrorLabel" styleClass="error-text" wrapText="true"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
<!-- Container for linked accounts -->
|
||||||
<VBox>
|
<VBox>
|
||||||
<HBox spacing="3">
|
<HBox spacing="3">
|
||||||
<VBox>
|
<VBox>
|
||||||
|
@ -54,6 +56,14 @@
|
||||||
VBox.vgrow="NEVER"
|
VBox.vgrow="NEVER"
|
||||||
/>
|
/>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
<!-- Container for attachments -->
|
||||||
|
<VBox>
|
||||||
|
<Label text="Attachments" styleClass="bold-text"/>
|
||||||
|
<Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-text" wrapText="true"/>
|
||||||
|
<VBox fx:id="selectedFilesVBox" style="-fx-spacing: 3px; -fx-padding: 3px;" VBox.vgrow="NEVER"/>
|
||||||
|
<Label text="No attachments selected." fx:id="noSelectedFilesLabel"/>
|
||||||
|
<Button text="Select attachments" onAction="#selectAttachmentFile"/>
|
||||||
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
</ScrollPane>
|
</ScrollPane>
|
||||||
</center>
|
</center>
|
||||||
|
|
|
@ -36,11 +36,13 @@ CREATE TABLE transaction_attachment (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
uploaded_at TIMESTAMP NOT NULL,
|
uploaded_at TIMESTAMP NOT NULL,
|
||||||
transaction_id BIGINT NOT NULL,
|
transaction_id BIGINT NOT NULL,
|
||||||
content_path VARCHAR(1024) NOT NULL,
|
filename VARCHAR(255) NOT NULL,
|
||||||
content_type VARCHAR(255) NOT NULL,
|
content_type VARCHAR(255) NOT NULL,
|
||||||
CONSTRAINT fk_transaction_attachment_transaction
|
CONSTRAINT fk_transaction_attachment_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_transaction_attachment_filename
|
||||||
|
UNIQUE(transaction_id, filename)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE balance_record (
|
CREATE TABLE balance_record (
|
||||||
|
|
|
@ -5,3 +5,24 @@ profile, including but not limited to transaction attachments (receipts,
|
||||||
invoices, etc.), bank statements, or portfolio exports. These files are usually
|
invoices, etc.), bank statements, or portfolio exports. These files are usually
|
||||||
managed by the Perfin app through in-app actions, but you're also welcome to
|
managed by the Perfin app through in-app actions, but you're also welcome to
|
||||||
browse them directly, or even delete files you no longer want stored.
|
browse them directly, or even delete files you no longer want stored.
|
||||||
|
|
||||||
|
Here's an overview of where you can find everything:
|
||||||
|
|
||||||
|
- transaction-attachments/
|
||||||
|
This folder contains all files you've attached to transactions you've created.
|
||||||
|
Within this folder, you'll see a series of sub-folders organized by the date
|
||||||
|
at which attachments were uploaded, and in side each date folder, you'll find
|
||||||
|
one folder for each transaction. For example, your folder might look like this:
|
||||||
|
|
||||||
|
my-profile/
|
||||||
|
content/
|
||||||
|
transaction-attachments/
|
||||||
|
2023-12-28/
|
||||||
|
tx-2/
|
||||||
|
receipt.png
|
||||||
|
tx-3/
|
||||||
|
invoice.pdf
|
||||||
|
2024-01-04/
|
||||||
|
tx-4/
|
||||||
|
receipt.jpeg
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue