Add Transaction Properties #15
27
README.md
27
README.md
|
@ -37,3 +37,30 @@ to set the version everywhere that it needs to be.
|
|||
|
||||
Once that's done, the workflow will start, and you should see a release appear
|
||||
in the next few minutes.
|
||||
|
||||
## Migration Procedure
|
||||
|
||||
Because this application relies on a structured relational database schema,
|
||||
changes to the schema must be handled with care to avoid destroying users' data.
|
||||
Specifically, when changes are made to the schema, a *migration* must be defined
|
||||
which provides instructions for Perfin to safely apply changes to an old schema.
|
||||
|
||||
The database schema is versioned using whole-number versions (1, 2, 3, ...), and
|
||||
a migration is defined for each transition from version to version, such that
|
||||
any older version can be incrementally upgraded, step by step, to the latest
|
||||
schema version.
|
||||
|
||||
Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
|
||||
When the app loads a profile, it'll check that profile's schema version by
|
||||
reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
|
||||
the profile's schema version is **less than** the current, Perfin will
|
||||
ask the user if they want to upgrade. If the profile's schema version is
|
||||
**greater than** the current, Perfin will tell the user that it can't load a
|
||||
schema from a newer version, and will prompt the user to upgrade.
|
||||
|
||||
### Writing a Migration
|
||||
|
||||
1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
|
||||
2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
|
||||
3. Increment the schema version defined in `JdbcDataSourceFactory`.
|
||||
4. Test the migration yourself on a profile with data.
|
||||
|
|
2
pom.xml
2
pom.xml
|
@ -6,7 +6,7 @@
|
|||
|
||||
<groupId>com.andrewlalis</groupId>
|
||||
<artifactId>perfin</artifactId>
|
||||
<version>1.4.0</version>
|
||||
<version>1.5.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
|
|
|
@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
|
|||
|
||||
jpackage \
|
||||
--name "Perfin" \
|
||||
--app-version "1.4.0" \
|
||||
--app-version "1.5.0" \
|
||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
|
||||
--icon design/perfin-logo_256.png \
|
||||
--vendor "Andrew Lalis" \
|
||||
|
|
|
@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
|
|||
|
||||
jpackage `
|
||||
--name "Perfin" `
|
||||
--app-version "1.4.0" `
|
||||
--app-version "1.5.0" `
|
||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
|
||||
--icon design\perfin-logo_256.ico `
|
||||
--vendor "Andrew Lalis" `
|
||||
|
|
|
@ -3,7 +3,9 @@ package com.andrewlalis.perfin;
|
|||
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
|
||||
import com.andrewlalis.javafx_scene_router.SceneRouter;
|
||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.ProfileLoader;
|
||||
import com.andrewlalis.perfin.view.ImageCache;
|
||||
import com.andrewlalis.perfin.view.SceneUtil;
|
||||
import com.andrewlalis.perfin.view.StartupSplashScreen;
|
||||
|
@ -29,6 +31,7 @@ public class PerfinApp extends Application {
|
|||
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
||||
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
||||
public static PerfinApp instance;
|
||||
public static ProfileLoader profileLoader;
|
||||
|
||||
/**
|
||||
* The router that's used for navigating between different "pages" in the application.
|
||||
|
@ -48,13 +51,14 @@ public class PerfinApp extends Application {
|
|||
@Override
|
||||
public void start(Stage stage) {
|
||||
instance = this;
|
||||
profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
|
||||
loadFonts();
|
||||
var splashScreen = new StartupSplashScreen(List.of(
|
||||
PerfinApp::defineRoutes,
|
||||
PerfinApp::initAppDir,
|
||||
c -> initMainScreen(stage, c),
|
||||
PerfinApp::loadLastUsedProfile
|
||||
));
|
||||
), false);
|
||||
splashScreen.showAndWait();
|
||||
if (splashScreen.isStartupSuccessful()) {
|
||||
stage.show();
|
||||
|
@ -87,6 +91,11 @@ public class PerfinApp extends Application {
|
|||
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
|
||||
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
|
||||
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
|
||||
router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
|
||||
router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
|
||||
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
|
||||
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
|
||||
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
|
||||
|
||||
// Help pages.
|
||||
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
|
||||
|
@ -112,9 +121,10 @@ public class PerfinApp extends Application {
|
|||
}
|
||||
|
||||
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
|
||||
msgConsumer.accept("Loading the most recent profile.");
|
||||
String lastProfile = ProfileLoader.getLastProfile();
|
||||
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
|
||||
try {
|
||||
Profile.loadLast();
|
||||
Profile.setCurrent(profileLoader.load(lastProfile));
|
||||
} catch (ProfileLoadException e) {
|
||||
msgConsumer.accept("Failed to load the profile: " + e.getMessage());
|
||||
throw e;
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
|
@ -64,7 +64,7 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
accountNumberLabel.setText(account.getAccountNumber());
|
||||
accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
|
||||
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
|
||||
Profile.getCurrent().getDataSource().getAccountBalanceText(account)
|
||||
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
||||
.thenAccept(accountBalanceLabel::setText);
|
||||
|
||||
reloadHistory();
|
||||
|
@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
@FXML
|
||||
public void archiveAccount() {
|
||||
boolean confirmResult = Popups.confirm(
|
||||
titleLabel,
|
||||
"Are you sure you want to archive this account? It will no " +
|
||||
"longer show up in the app normally, and you won't be " +
|
||||
"able to add new transactions to it. You'll still be " +
|
||||
|
@ -96,18 +97,19 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
"later if you need to."
|
||||
);
|
||||
if (confirmResult) {
|
||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
|
||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
|
||||
router.replace("accounts");
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void unarchiveAccount() {
|
||||
boolean confirm = Popups.confirm(
|
||||
titleLabel,
|
||||
"Are you sure you want to restore this account from its archived " +
|
||||
"status?"
|
||||
);
|
||||
if (confirm) {
|
||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
|
||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
|
||||
router.replace("accounts");
|
||||
}
|
||||
}
|
||||
|
@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
@FXML
|
||||
public void deleteAccount() {
|
||||
boolean confirm = Popups.confirm(
|
||||
titleLabel,
|
||||
"Are you sure you want to permanently delete this account and " +
|
||||
"all data directly associated with it? This cannot be " +
|
||||
"undone; deleted accounts are not recoverable at all. " +
|
||||
|
@ -122,26 +125,21 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
"want to hide it."
|
||||
);
|
||||
if (confirm) {
|
||||
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
|
||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
|
||||
router.replace("accounts");
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void loadMoreHistory() {
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> {
|
||||
List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount(
|
||||
account.id,
|
||||
loadHistoryFrom,
|
||||
historyLoadSize
|
||||
);
|
||||
if (historyItems.size() < historyLoadSize) {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
|
||||
long historyId = repo.getOrCreateHistoryForAccount(account.id);
|
||||
List<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
|
||||
if (items.size() < historyLoadSize) {
|
||||
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
|
||||
} else {
|
||||
loadHistoryFrom = historyItems.getLast().getTimestamp();
|
||||
loadHistoryFrom = items.getLast().getTimestamp();
|
||||
}
|
||||
List<? extends Node> nodes = historyItems.stream()
|
||||
.map(item -> AccountHistoryItemTile.forItem(item, repo, this))
|
||||
.toList();
|
||||
List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
|
||||
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ public class AccountsViewController implements RouteSelectionListener {
|
|||
|
||||
public void refreshAccounts() {
|
||||
Profile.whenLoaded(profile -> {
|
||||
profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
List<Account> accounts = repo.findAllOrderedByRecentHistory();
|
||||
Platform.runLater(() -> accountsPane.getChildren()
|
||||
.setAll(accounts.stream()
|
||||
|
@ -59,7 +59,7 @@ public class AccountsViewController implements RouteSelectionListener {
|
|||
});
|
||||
// Compute grand totals!
|
||||
Thread.ofVirtual().start(() -> {
|
||||
var totals = profile.getDataSource().getCombinedAccountBalances();
|
||||
var totals = profile.dataSource().getCombinedAccountBalances();
|
||||
StringBuilder sb = new StringBuilder("Totals: ");
|
||||
for (var entry : totals.entrySet()) {
|
||||
sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
|
||||
|
|
|
@ -41,16 +41,19 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
|||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
||||
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
||||
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
|
||||
List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
|
||||
Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
|
||||
});
|
||||
}
|
||||
|
||||
@FXML public void delete() {
|
||||
boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin.");
|
||||
boolean confirm = Popups.confirm(
|
||||
titleLabel,
|
||||
"Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."
|
||||
);
|
||||
if (confirm) {
|
||||
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
|
||||
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.CategoryTile;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class CategoriesViewController implements RouteSelectionListener {
|
||||
@FXML public VBox categoriesVBox;
|
||||
private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public void initialize() {
|
||||
BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
refreshCategories();
|
||||
}
|
||||
|
||||
@FXML public void addCategory() {
|
||||
router.navigate("edit-category");
|
||||
}
|
||||
|
||||
private void refreshCategories() {
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionCategoryRepository.class,
|
||||
TransactionCategoryRepository::findTree
|
||||
).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
|
||||
}
|
||||
|
||||
@FXML public void addDefaultCategories() {
|
||||
boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
|
||||
if (!confirm) return;
|
||||
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||
try (var conn = ds.getConnection()) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
try {
|
||||
new JdbcDataSourceFactory().insertDefaultCategories(conn);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
refreshCategories();
|
||||
} catch (Exception e) {
|
||||
Popups.error(categoriesVBox, e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
|
|||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||
import javafx.application.Platform;
|
||||
|
@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
private Account account;
|
||||
|
||||
@FXML public void initialize() {
|
||||
var timestampValid = new ValidationApplier<String>(input -> {
|
||||
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
|
||||
try {
|
||||
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
|
||||
return ValidationResult.valid();
|
||||
|
@ -60,7 +61,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
return;
|
||||
}
|
||||
BigDecimal reportedBalance = new BigDecimal(newValue);
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
|
||||
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
|
||||
!reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
|
||||
|
@ -76,7 +77,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
public void onRouteSelected(Object context) {
|
||||
this.account = (Account) context;
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal value = repo.deriveCurrentBalance(account.id);
|
||||
Platform.runLater(() -> balanceField.setText(
|
||||
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
||||
|
@ -89,13 +90,13 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
||||
|
||||
boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
|
||||
boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
|
||||
account.getShortName(),
|
||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
||||
));
|
||||
if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
|
||||
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||
repo.insert(
|
||||
DateUtil.localToUTC(localTimestamp),
|
||||
account.id,
|
||||
|
@ -113,7 +114,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
}
|
||||
|
||||
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
|
||||
BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo(
|
||||
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
||||
AccountRepository.class,
|
||||
repo -> repo.deriveCurrentBalance(account.id)
|
||||
);
|
||||
|
@ -122,7 +123,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
|
||||
);
|
||||
return Popups.confirm(msg);
|
||||
return Popups.confirm(timestampField, msg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -106,8 +106,8 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
@FXML
|
||||
public void save() {
|
||||
try (
|
||||
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
|
||||
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
|
||||
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
|
||||
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
|
||||
) {
|
||||
if (creatingNewAccount.get()) {
|
||||
String name = accountNameField.getText().strip();
|
||||
|
@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
|
||||
List<Path> attachments = Collections.emptyList();
|
||||
|
||||
boolean success = Popups.confirm("Are you sure you want to create this account?");
|
||||
boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?");
|
||||
if (success) {
|
||||
long id = accountRepo.insert(type, number, name, currency);
|
||||
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
|
||||
|
@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save (or update) account " + account.id, e);
|
||||
Popups.error("Failed to save the account: " + e.getMessage());
|
||||
Popups.error(accountNameField, "Failed to save the account: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ColorPicker;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class EditCategoryController implements RouteSelectionListener {
|
||||
public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
|
||||
public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
|
||||
private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
|
||||
|
||||
private TransactionCategory category;
|
||||
private TransactionCategory parent;
|
||||
|
||||
@FXML public TextField nameField;
|
||||
@FXML public ColorPicker colorPicker;
|
||||
|
||||
@FXML public Button saveButton;
|
||||
|
||||
@FXML public void initialize() {
|
||||
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||
.addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
|
||||
.addAsyncPredicate(
|
||||
s -> {
|
||||
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
|
||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionCategoryRepository.class,
|
||||
repo -> {
|
||||
var categoryByName = repo.findByName(s).orElse(null);
|
||||
if (this.category != null) {
|
||||
return this.category.equals(categoryByName) || categoryByName == null;
|
||||
}
|
||||
return categoryByName == null;
|
||||
}
|
||||
);
|
||||
},
|
||||
"Category with this name already exists."
|
||||
)
|
||||
).validatedInitially().attachToTextField(nameField);
|
||||
|
||||
saveButton.disableProperty().bind(nameValid.not());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
this.category = null;
|
||||
this.parent = null;
|
||||
if (context instanceof RouteContext ctx) {
|
||||
switch (ctx) {
|
||||
case CategoryRouteContext(var cat):
|
||||
this.category = cat;
|
||||
nameField.setText(cat.getName());
|
||||
colorPicker.setValue(cat.getColor());
|
||||
break;
|
||||
case AddSubcategoryRouteContext(var par):
|
||||
this.parent = par;
|
||||
nameField.setText(null);
|
||||
colorPicker.setValue(parent.getColor());
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
nameField.setText(null);
|
||||
colorPicker.setValue(Color.WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void save() {
|
||||
final String name = nameField.getText().strip();
|
||||
final Color color = colorPicker.getValue();
|
||||
if (this.category == null && this.parent == null) {
|
||||
// New top-level category.
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionCategoryRepository.class,
|
||||
repo -> repo.insert(name, color)
|
||||
);
|
||||
} else if (this.category == null) {
|
||||
// New subcategory.
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionCategoryRepository.class,
|
||||
repo -> repo.insert(parent.id, name, color)
|
||||
);
|
||||
} else if (this.parent == null) {
|
||||
// Save edits to an existing category.
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionCategoryRepository.class,
|
||||
repo -> repo.update(category.id, name, color)
|
||||
);
|
||||
}
|
||||
router.replace("categories");
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
}
|
|
@ -1,24 +1,36 @@
|
|||
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.CategorySelectionBox;
|
||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
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,13 +39,14 @@ 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;
|
||||
|
||||
/**
|
||||
* Controller for the "edit-transaction" view, which is where the user can
|
||||
* create or edit transactions.
|
||||
*/
|
||||
public class EditTransactionController implements RouteSelectionListener {
|
||||
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
|
||||
|
||||
|
@ -49,6 +62,25 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@FXML public AccountSelectionBox debitAccountSelector;
|
||||
@FXML public AccountSelectionBox creditAccountSelector;
|
||||
|
||||
@FXML public ComboBox<String> vendorComboBox;
|
||||
@FXML public Hyperlink vendorsHyperlink;
|
||||
@FXML public CategorySelectionBox categoryComboBox;
|
||||
@FXML public Hyperlink categoriesHyperlink;
|
||||
@FXML public ComboBox<String> tagsComboBox;
|
||||
@FXML public Hyperlink tagsHyperlink;
|
||||
@FXML public Button addTagButton;
|
||||
@FXML public VBox tagsVBox;
|
||||
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
||||
@FXML public TextField lineItemValueField;
|
||||
@FXML public TextField lineItemDescriptionField;
|
||||
@FXML public Button addLineItemButton;
|
||||
@FXML public VBox addLineItemForm;
|
||||
@FXML public Button addLineItemAddButton;
|
||||
@FXML public Button addLineItemCancelButton;
|
||||
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
||||
|
||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||
|
||||
@FXML public Button saveButton;
|
||||
|
@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||
initializeTagSelectionUi();
|
||||
|
||||
// Linked accounts will use a property derived from both the debit and credit selections.
|
||||
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);
|
||||
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
||||
|
||||
// Initialize line item stuff.
|
||||
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
||||
addLineItemCancelButton.setOnAction(event -> {
|
||||
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
||||
lineItemValueField.setText(null);
|
||||
lineItemDescriptionField.setText(null);
|
||||
addingLineItemProperty.set(false);
|
||||
});
|
||||
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
||||
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
||||
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
||||
var lineItemValueValid = new ValidationApplier<>(
|
||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
||||
).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
||||
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
||||
).attachToTextField(lineItemDescriptionField);
|
||||
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
||||
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
||||
|
||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
|
@ -107,11 +139,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
Currency currency = currencyChoiceBox.getValue();
|
||||
String description = getSanitizedDescription();
|
||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||
String vendor = vendorComboBox.getValue();
|
||||
String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
|
||||
Set<String> tags = new HashSet<>(selectedTags);
|
||||
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
||||
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
||||
final long idToNavigate;
|
||||
if (transaction == null) {
|
||||
idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
|
||||
idToNavigate = Profile.getCurrent().dataSource().mapRepo(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.insert(
|
||||
utcTimestamp,
|
||||
|
@ -119,11 +154,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
currency,
|
||||
description,
|
||||
linkedAccounts,
|
||||
vendor,
|
||||
category,
|
||||
tags,
|
||||
newAttachmentPaths
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Profile.getCurrent().getDataSource().useRepo(
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.update(
|
||||
transaction.id,
|
||||
|
@ -132,6 +170,9 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
currency,
|
||||
description,
|
||||
linkedAccounts,
|
||||
vendor,
|
||||
category,
|
||||
tags,
|
||||
existingAttachments,
|
||||
newAttachmentPaths
|
||||
)
|
||||
|
@ -149,6 +190,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.select(null);
|
||||
|
||||
if (transaction == null) {
|
||||
titleLabel.setText("Create New Transaction");
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
|
@ -163,10 +209,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().getDataSource().getAccountRepository();
|
||||
var transactionRepo = Profile.getCurrent().getDataSource().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 +223,50 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
.toList();
|
||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
final List<Attachment> attachments;
|
||||
final var categoryTreeNodes = categoryRepo.findTree();
|
||||
final List<String> availableTags = transactionRepo.findAllTags();
|
||||
final List<String> tags;
|
||||
final CreditAndDebitAccounts linkedAccounts;
|
||||
final String vendorName;
|
||||
final TransactionCategory category;
|
||||
if (transaction == null) {
|
||||
attachments = Collections.emptyList();
|
||||
tags = Collections.emptyList();
|
||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||
vendorName = null;
|
||||
category = 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) {
|
||||
category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
|
||||
} else {
|
||||
category = null;
|
||||
}
|
||||
}
|
||||
final List<TransactionVendor> availableVendors = vendorRepo.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.loadCategories(categoryTreeNodes);
|
||||
categoryComboBox.select(category);
|
||||
tagsComboBox.getItems().setAll(availableTags);
|
||||
attachmentsSelectionArea.clear();
|
||||
attachmentsSelectionArea.addAttachments(attachments);
|
||||
selectedTags.clear();
|
||||
selectedTags.addAll(tags);
|
||||
if (transaction == null) {
|
||||
// TODO: Allow user to select a default currency.
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
creditAccountSelector.select(null);
|
||||
debitAccountSelector.select(null);
|
||||
|
@ -203,11 +279,53 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get repositories.", e);
|
||||
Popups.error("Failed to fetch account-specific data: " + e.getMessage());
|
||||
Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private BooleanExpression initializeLinkedAccountsValidationUi() {
|
||||
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
|
||||
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||
return new ValidationApplier<>(getLinkedAccountsValidator())
|
||||
.validatedInitially()
|
||||
.attach(linkedAccountsContainer, linkedAccountsProperty);
|
||||
}
|
||||
|
||||
private void initializeTagSelectionUi() {
|
||||
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, this::createTagListTile);
|
||||
}
|
||||
|
||||
private Node createTagListTile(String 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;
|
||||
}
|
||||
|
||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||
return new CreditAndDebitAccounts(
|
||||
creditAccountSelector.getValue(),
|
||||
|
@ -215,6 +333,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,
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class EditVendorController implements RouteSelectionListener {
|
||||
private TransactionVendor vendor;
|
||||
|
||||
@FXML public TextField nameField;
|
||||
@FXML public TextArea descriptionField;
|
||||
@FXML public Button saveButton;
|
||||
|
||||
@FXML public void initialize() {
|
||||
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||
.addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
|
||||
// A predicate that prevents duplicate names.
|
||||
.addAsyncPredicate(
|
||||
s -> {
|
||||
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
|
||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionVendorRepository.class,
|
||||
repo -> {
|
||||
var vendorByName = repo.findByName(s).orElse(null);
|
||||
if (this.vendor != null) {
|
||||
return this.vendor.equals(vendorByName) || vendorByName == null;
|
||||
}
|
||||
return vendorByName == null;
|
||||
}
|
||||
);
|
||||
},
|
||||
"Vendor with this name already exists."
|
||||
)
|
||||
).validatedInitially().attachToTextField(nameField);
|
||||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addPredicate(
|
||||
s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
|
||||
"Description is too long."
|
||||
)
|
||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||
|
||||
var formValid = nameValid.and(descriptionValid);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
if (context instanceof TransactionVendor tv) {
|
||||
this.vendor = tv;
|
||||
nameField.setText(vendor.getName());
|
||||
descriptionField.setText(vendor.getDescription());
|
||||
} else {
|
||||
nameField.setText(null);
|
||||
descriptionField.setText(null);
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void save() {
|
||||
String name = nameField.getText().strip();
|
||||
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
|
||||
DataSource ds = Profile.getCurrent().dataSource();
|
||||
if (vendor != null) {
|
||||
ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
|
||||
} else {
|
||||
ds.useRepo(TransactionVendorRepository.class, repo -> {
|
||||
if (description == null || description.isEmpty()) {
|
||||
repo.insert(name);
|
||||
} else {
|
||||
repo.insert(name, description);
|
||||
}
|
||||
});
|
||||
}
|
||||
router.replace("vendors");
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
}
|
|
@ -1,30 +1,70 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Window;
|
||||
|
||||
/**
|
||||
* Helper class for standardized popups and confirmation dialogs for the app.
|
||||
*/
|
||||
public class Popups {
|
||||
public static boolean confirm(String text) {
|
||||
public static boolean confirm(Window owner, String text) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
|
||||
alert.initOwner(owner);
|
||||
alert.initModality(Modality.APPLICATION_MODAL);
|
||||
var result = alert.showAndWait();
|
||||
return result.isPresent() && result.get() == ButtonType.OK;
|
||||
}
|
||||
|
||||
public static void message(String text) {
|
||||
public static boolean confirm(Node node, String text) {
|
||||
return confirm(getWindowFromNode(node), text);
|
||||
}
|
||||
|
||||
public static void message(Window owner, String text) {
|
||||
Alert alert = new Alert(Alert.AlertType.NONE, text);
|
||||
alert.initOwner(owner);
|
||||
alert.initModality(Modality.APPLICATION_MODAL);
|
||||
alert.getButtonTypes().setAll(ButtonType.OK);
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
public static void error(String text) {
|
||||
public static void message(Node node, String text) {
|
||||
message(getWindowFromNode(node), text);
|
||||
}
|
||||
|
||||
public static void error(Window owner, String text) {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING, text);
|
||||
alert.initOwner(owner);
|
||||
alert.initModality(Modality.APPLICATION_MODAL);
|
||||
alert.showAndWait();
|
||||
}
|
||||
|
||||
public static void error(Node node, String text) {
|
||||
error(getWindowFromNode(node), text);
|
||||
}
|
||||
|
||||
public static void error(Window owner, Exception e) {
|
||||
error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage());
|
||||
}
|
||||
|
||||
public static void error(Node node, Exception e) {
|
||||
error(getWindowFromNode(node), e);
|
||||
}
|
||||
|
||||
public static void errorLater(Node node, Exception e) {
|
||||
Platform.runLater(() -> error(node, e));
|
||||
}
|
||||
|
||||
private static Window getWindowFromNode(Node n) {
|
||||
Window owner = null;
|
||||
Scene scene = n.getScene();
|
||||
if (scene != null) {
|
||||
owner = scene.getWindow();
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
|
|||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.ProfileLoader;
|
||||
import com.andrewlalis.perfin.view.ProfilesStage;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
|
@ -44,11 +45,11 @@ public class ProfilesViewController {
|
|||
@FXML public void addProfile() {
|
||||
String name = newProfileNameField.getText();
|
||||
boolean valid = Profile.validateName(name);
|
||||
if (valid && !Profile.getAvailableProfiles().contains(name)) {
|
||||
boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
|
||||
if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
|
||||
boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?");
|
||||
if (confirm) {
|
||||
if (openProfile(name, false)) {
|
||||
Popups.message("Created new profile \"" + name + "\" and loaded it.");
|
||||
Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it.");
|
||||
}
|
||||
newProfileNameField.clear();
|
||||
}
|
||||
|
@ -56,8 +57,8 @@ public class ProfilesViewController {
|
|||
}
|
||||
|
||||
private void refreshAvailableProfiles() {
|
||||
List<String> profileNames = Profile.getAvailableProfiles();
|
||||
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
|
||||
List<String> profileNames = ProfileLoader.getAvailableProfiles();
|
||||
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
|
||||
List<Node> nodes = new ArrayList<>(profileNames.size());
|
||||
for (String profileName : profileNames) {
|
||||
boolean isCurrent = profileName.equals(currentProfile);
|
||||
|
@ -104,30 +105,31 @@ public class ProfilesViewController {
|
|||
private boolean openProfile(String name, boolean showPopup) {
|
||||
log.info("Opening profile \"{}\".", name);
|
||||
try {
|
||||
Profile.load(name);
|
||||
Profile.setCurrent(PerfinApp.profileLoader.load(name));
|
||||
ProfileLoader.saveLastProfile(name);
|
||||
ProfilesStage.closeView();
|
||||
router.replace("accounts");
|
||||
if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded.");
|
||||
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
|
||||
return true;
|
||||
} catch (ProfileLoadException e) {
|
||||
Popups.error("Failed to load the profile: " + e.getMessage());
|
||||
Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteProfile(String name) {
|
||||
boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
|
||||
boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
|
||||
if (confirmA) {
|
||||
boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
|
||||
boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
|
||||
if (confirmB) {
|
||||
try {
|
||||
FileUtil.deleteDirRecursive(Profile.getDir(name));
|
||||
// Reset the app's "last profile" to the default if it was the deleted profile.
|
||||
if (Profile.getLastProfile().equals(name)) {
|
||||
Profile.saveLastProfile("default");
|
||||
if (ProfileLoader.getLastProfile().equals(name)) {
|
||||
ProfileLoader.saveLastProfile("default");
|
||||
}
|
||||
// If the current profile was deleted, switch to the default.
|
||||
if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) {
|
||||
if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
|
||||
openProfile("default", true);
|
||||
}
|
||||
refreshAvailableProfiles();
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
public class TagsViewController implements RouteSelectionListener {
|
||||
@FXML public VBox tagsVBox;
|
||||
private final ObservableList<String> tags = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public void initialize() {
|
||||
BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
refreshTags();
|
||||
}
|
||||
|
||||
private void refreshTags() {
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionRepository.class,
|
||||
TransactionRepository::findAllTags
|
||||
).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
|
||||
}
|
||||
|
||||
private Node buildTagTile(String name) {
|
||||
BorderPane tile = new BorderPane();
|
||||
tile.getStyleClass().addAll("tile");
|
||||
Label nameLabel = new Label(name);
|
||||
nameLabel.getStyleClass().addAll("bold-text");
|
||||
Label usagesLabel = new Label();
|
||||
usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.countTagUsages(name)
|
||||
).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
|
||||
VBox contentBox = new VBox(nameLabel, usagesLabel);
|
||||
tile.setLeft(contentBox);
|
||||
Button removeButton = new Button("Remove");
|
||||
removeButton.setOnAction(event -> {
|
||||
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
|
||||
if (confirm) {
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.deleteTag(name)
|
||||
);
|
||||
refreshTags();
|
||||
}
|
||||
});
|
||||
tile.setRight(removeButton);
|
||||
return tile;
|
||||
}
|
||||
}
|
|
@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
|
|||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
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.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class TransactionViewController {
|
||||
private Transaction transaction;
|
||||
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
||||
|
||||
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
||||
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
||||
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public Label titleLabel;
|
||||
|
||||
|
@ -27,51 +41,108 @@ public class TransactionViewController {
|
|||
@FXML public Label timestampLabel;
|
||||
@FXML public Label descriptionLabel;
|
||||
|
||||
@FXML public Label vendorLabel;
|
||||
@FXML public Circle categoryColorIndicator;
|
||||
@FXML public Label categoryLabel;
|
||||
@FXML public Label tagsLabel;
|
||||
|
||||
@FXML public Hyperlink debitAccountLink;
|
||||
@FXML public Hyperlink creditAccountLink;
|
||||
|
||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||
|
||||
@FXML public void initialize() {
|
||||
configureAccountLinkBindings(debitAccountLink);
|
||||
configureAccountLinkBindings(creditAccountLink);
|
||||
titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
|
||||
amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
|
||||
timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
|
||||
descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
|
||||
|
||||
PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
|
||||
BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
|
||||
vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
|
||||
|
||||
PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
|
||||
BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
|
||||
categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
|
||||
categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
|
||||
|
||||
PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
|
||||
BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
|
||||
tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
|
||||
|
||||
TextFlow debitText = (TextFlow) debitAccountLink.getParent();
|
||||
BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
|
||||
debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
|
||||
debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
|
||||
if (la.hasDebit()) {
|
||||
return event -> router.navigate("account", la.debitAccount());
|
||||
}
|
||||
return event -> {};
|
||||
}));
|
||||
TextFlow creditText = (TextFlow) creditAccountLink.getParent();
|
||||
BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
|
||||
creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
|
||||
creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
|
||||
if (la.hasCredit()) {
|
||||
return event -> router.navigate("account", la.creditAccount());
|
||||
}
|
||||
return event -> {};
|
||||
}));
|
||||
|
||||
attachmentsViewPane.hideIfEmpty();
|
||||
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
||||
|
||||
transactionProperty.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue == null) {
|
||||
linkedAccountsProperty.set(null);
|
||||
vendorProperty.set(null);
|
||||
categoryProperty.set(null);
|
||||
tagsList.clear();
|
||||
attachmentsList.clear();
|
||||
} else {
|
||||
updateLinkedData(newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setTransaction(Transaction transaction) {
|
||||
this.transaction = transaction;
|
||||
if (transaction == null) return;
|
||||
titleLabel.setText("Transaction #" + transaction.id);
|
||||
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
|
||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
|
||||
descriptionLabel.setText(transaction.getDescription());
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
||||
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
|
||||
List<Attachment> attachments = repo.findAttachments(transaction.id);
|
||||
Platform.runLater(() -> {
|
||||
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);
|
||||
});
|
||||
this.transactionProperty.set(transaction);
|
||||
}
|
||||
|
||||
private void updateLinkedData(Transaction tx) {
|
||||
var ds = Profile.getCurrent().dataSource();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (
|
||||
var transactionRepo = ds.getTransactionRepository();
|
||||
var vendorRepo = ds.getTransactionVendorRepository();
|
||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
||||
) {
|
||||
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
||||
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
||||
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
||||
final var attachments = transactionRepo.findAttachments(tx.id);
|
||||
final var tags = transactionRepo.findTags(tx.id);
|
||||
Platform.runLater(() -> {
|
||||
linkedAccountsProperty.set(linkedAccounts);
|
||||
vendorProperty.set(vendor);
|
||||
categoryProperty.set(category);
|
||||
attachmentsList.setAll(attachments);
|
||||
tagsList.setAll(tags);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch additional transaction data.", e);
|
||||
Popups.errorLater(titleLabel, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML public void editTransaction() {
|
||||
router.navigate("edit-transaction", this.transaction);
|
||||
router.navigate("edit-transaction", this.transactionProperty.get());
|
||||
}
|
||||
|
||||
@FXML public void deleteTransaction() {
|
||||
boolean confirm = Popups.confirm(
|
||||
titleLabel,
|
||||
"Are you sure you want to delete this transaction? This will " +
|
||||
"permanently remove the transaction and its effects on any linked " +
|
||||
"accounts, as well as remove any attachments from storage within " +
|
||||
|
@ -81,15 +152,8 @@ public class TransactionViewController {
|
|||
"it's derived from the most recent balance-record, and transactions."
|
||||
);
|
||||
if (confirm) {
|
||||
Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id));
|
||||
Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
|
||||
router.replace("transactions");
|
||||
}
|
||||
}
|
||||
|
||||
private void configureAccountLinkBindings(Hyperlink link) {
|
||||
TextFlow parent = (TextFlow) link.getParent();
|
||||
parent.managedProperty().bind(parent.visibleProperty());
|
||||
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
|
||||
link.setText(null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,14 +3,18 @@ package com.andrewlalis.perfin.control;
|
|||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
|
||||
import com.andrewlalis.perfin.data.search.SearchFilter;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.SceneUtil;
|
||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
|
||||
|
@ -21,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
|
|||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
@ -29,8 +34,9 @@ import javafx.stage.FileChooser;
|
|||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
|
@ -45,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
public record RouteContext(Long selectedTransactionId) {}
|
||||
|
||||
@FXML public BorderPane transactionsListBorderPane;
|
||||
@FXML public TextField searchField;
|
||||
@FXML public AccountSelectionBox filterByAccountComboBox;
|
||||
@FXML public VBox transactionsVBox;
|
||||
private DataSourcePaginationControls paginationControls;
|
||||
|
@ -59,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
});
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
});
|
||||
|
||||
this.paginationControls = new DataSourcePaginationControls(
|
||||
transactionsVBox.getChildren(),
|
||||
new DataSourcePaginationControls.PageFetcherFunction() {
|
||||
@Override
|
||||
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
|
||||
Account accountFilter = filterByAccountComboBox.getValue();
|
||||
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
|
||||
Page<Transaction> result;
|
||||
if (accountFilter == null) {
|
||||
result = repo.findAll(pagination);
|
||||
} else {
|
||||
result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
|
||||
}
|
||||
return result.map(TransactionsViewController.this::makeTile);
|
||||
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||
try (var conn = ds.getConnection()) {
|
||||
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
|
||||
return searcher.search(pagination, getCurrentSearchFilters())
|
||||
.map(TransactionsViewController.this::makeTile);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalCount() throws Exception {
|
||||
Account accountFilter = filterByAccountComboBox.getValue();
|
||||
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
|
||||
if (accountFilter == null) {
|
||||
return (int) repo.countAll();
|
||||
} else {
|
||||
return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
|
||||
}
|
||||
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||
try (var conn = ds.getConnection()) {
|
||||
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
|
||||
return (int) searcher.resultCount(getCurrentSearchFilters());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -98,18 +102,13 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
detailPanel.minWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.maxWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.prefWidthProperty().bind(halfWidthProp);
|
||||
detailPanel.managedProperty().bind(detailPanel.visibleProperty());
|
||||
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
|
||||
BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
|
||||
|
||||
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
|
||||
TransactionViewController transactionViewController = detailComponents.second();
|
||||
BorderPane transactionDetailView = detailComponents.first();
|
||||
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
|
||||
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
|
||||
detailPanel.getChildren().add(transactionDetailView);
|
||||
selectedTransaction.addListener((observable, oldValue, newValue) -> {
|
||||
transactionViewController.setTransaction(newValue);
|
||||
});
|
||||
selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
|
||||
|
||||
// Clear the transactions when a new profile is loaded.
|
||||
Profile.whenLoaded(profile -> {
|
||||
|
@ -121,10 +120,10 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
paginationControls.sorts.setAll(DEFAULT_SORTS);
|
||||
transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially.
|
||||
selectedTransaction.set(null); // Initially set the selected transaction as null.
|
||||
|
||||
// Refresh account filter options.
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||
Platform.runLater(() -> {
|
||||
filterByAccountComboBox.setAccounts(accounts);
|
||||
|
@ -135,18 +134,19 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
|
||||
// If a transaction id is given in the route context, navigate to the page it's on and select it.
|
||||
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
||||
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
||||
long offset = repo.countAllAfter(tx.id);
|
||||
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
|
||||
Platform.runLater(() -> {
|
||||
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
|
||||
});
|
||||
});
|
||||
});
|
||||
Profile.getCurrent().dataSource().useRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
||||
long offset = repo.countAllAfter(tx.id);
|
||||
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
|
||||
Platform.runLater(() -> {
|
||||
paginationControls.setPage(pageNumber);
|
||||
selectedTransaction.set(tx);
|
||||
});
|
||||
})
|
||||
);
|
||||
} else {
|
||||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -161,7 +161,7 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
|
||||
if (file != null) {
|
||||
try (
|
||||
var repo = Profile.getCurrent().getDataSource().getTransactionRepository();
|
||||
var repo = Profile.getCurrent().dataSource().getTransactionRepository();
|
||||
var out = new PrintWriter(file, StandardCharsets.UTF_8)
|
||||
) {
|
||||
out.println("id,utc-timestamp,amount,currency,description");
|
||||
|
@ -177,11 +177,42 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Popups.error("An error occurred: " + e.getMessage());
|
||||
Popups.error(transactionsListBorderPane, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SearchFilter> getCurrentSearchFilters() {
|
||||
List<SearchFilter> filters = new ArrayList<>();
|
||||
if (searchField.getText() != null && !searchField.getText().isBlank()) {
|
||||
var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
|
||||
.map(t -> '%'+t+'%')
|
||||
.toList();
|
||||
var builder = new SearchFilter.Builder();
|
||||
List<String> orClauses = new ArrayList<>(likeTerms.size());
|
||||
for (var term : likeTerms) {
|
||||
orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
|
||||
builder.withArg(term);
|
||||
builder.withArg(term);
|
||||
builder.withArg(term);
|
||||
}
|
||||
builder.where(String.join(" OR ", orClauses));
|
||||
builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
|
||||
builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
|
||||
filters.add(builder.build());
|
||||
}
|
||||
if (filterByAccountComboBox.getValue() != null) {
|
||||
Account filteredAccount = filterByAccountComboBox.getValue();
|
||||
var filter = new SearchFilter.Builder()
|
||||
.where("fae.account_id = ?")
|
||||
.withArg(filteredAccount.id)
|
||||
.withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
|
||||
.build();
|
||||
filters.add(filter);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
private TransactionTile makeTile(Transaction transaction) {
|
||||
var tile = new TransactionTile(transaction);
|
||||
tile.setOnMouseClicked(event -> {
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.VendorTile;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class VendorsViewController implements RouteSelectionListener {
|
||||
@FXML public VBox vendorsVBox;
|
||||
private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
|
||||
|
||||
@FXML public void initialize() {
|
||||
BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
refreshVendors();
|
||||
}
|
||||
|
||||
@FXML public void addVendor() {
|
||||
router.navigate("edit-vendor");
|
||||
}
|
||||
|
||||
private void refreshVendors() {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
|
||||
final List<TransactionVendor> vendors = repo.findAll();
|
||||
Platform.runLater(() -> {
|
||||
this.vendors.clear();
|
||||
this.vendors.addAll(vendors);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
|
||||
void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
|
||||
void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
|
||||
void recordText(LocalDateTime timestamp, long accountId, String text);
|
||||
List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
|
||||
default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
|
||||
var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
|
||||
if (items.isEmpty()) return Optional.empty();
|
||||
return Optional.of(items.getFirst());
|
||||
}
|
||||
String getTextItem(long itemId);
|
||||
AccountEntry getAccountEntryItem(long itemId);
|
||||
BalanceRecord getBalanceRecordItem(long itemId);
|
||||
}
|
|
@ -30,8 +30,10 @@ public interface DataSource {
|
|||
AccountRepository getAccountRepository();
|
||||
BalanceRecordRepository getBalanceRecordRepository();
|
||||
TransactionRepository getTransactionRepository();
|
||||
TransactionVendorRepository getTransactionVendorRepository();
|
||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||
AttachmentRepository getAttachmentRepository();
|
||||
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
||||
HistoryRepository getHistoryRepository();
|
||||
|
||||
// Repository helper methods:
|
||||
|
||||
|
@ -81,8 +83,10 @@ 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
|
||||
HistoryRepository.class, this::getHistoryRepository
|
||||
);
|
||||
return (Supplier<R>) repoSuppliers.get(type);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Interface that defines the data source factory, a component responsible for
|
||||
* obtaining a data source, and performing some introspection around that data
|
||||
* source before one is obtained.
|
||||
*/
|
||||
public interface DataSourceFactory {
|
||||
DataSource getDataSource(String profileName) throws ProfileLoadException;
|
||||
|
||||
enum SchemaStatus {
|
||||
UP_TO_DATE,
|
||||
NEEDS_MIGRATION,
|
||||
INCOMPATIBLE
|
||||
}
|
||||
SchemaStatus getSchemaStatus(String profileName) throws IOException;
|
||||
|
||||
int getSchemaVersion(String profileName) throws IOException;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
public interface HistoryRepository extends Repository, AutoCloseable {
|
||||
long getOrCreateHistoryForAccount(long accountId);
|
||||
long getOrCreateHistoryForTransaction(long transactionId);
|
||||
void deleteHistoryForAccount(long accountId);
|
||||
void deleteHistoryForTransaction(long transactionId);
|
||||
|
||||
HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
|
||||
default HistoryTextItem addTextItem(long historyId, String description) {
|
||||
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
|
||||
}
|
||||
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
|
||||
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
|
||||
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
|
||||
return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
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 update(long id, String name, Color color);
|
||||
void deleteById(long id);
|
||||
|
||||
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){}
|
||||
List<CategoryTreeNode> findTree();
|
||||
}
|
|
@ -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,10 @@ 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 deleteTag(String name);
|
||||
long countTagUsages(String name);
|
||||
void delete(long transactionId);
|
||||
void update(
|
||||
long id,
|
||||
|
@ -39,6 +46,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,16 @@
|
|||
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 update(long id, String name, String description);
|
||||
void deleteById(long id);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
||||
|
@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
|
|||
)
|
||||
);
|
||||
// Insert an entry into the account's history.
|
||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordAccountEntry(timestamp, accountId, entryId);
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||
historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
|
||||
return entryId;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
|
||||
@Override
|
||||
public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
|
||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
|
||||
DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
|
||||
List.of(itemId, entryId)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
|
||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
|
||||
DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
|
||||
List.of(itemId, recordId)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recordText(LocalDateTime timestamp, long accountId, String text) {
|
||||
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
|
||||
DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
|
||||
List.of(itemId, text)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
|
||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
JdbcAccountHistoryItemRepository::parseHistoryItem
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTextItem(long itemId) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT description FROM account_history_item_text WHERE item_id = ?",
|
||||
List.of(itemId),
|
||||
rs -> rs.getString(1)
|
||||
).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountEntry getAccountEntryItem(long itemId) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"""
|
||||
SELECT *
|
||||
FROM account_entry
|
||||
LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
|
||||
WHERE h.item_id = ?""",
|
||||
List.of(itemId),
|
||||
JdbcAccountEntryRepository::parse
|
||||
).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BalanceRecord getBalanceRecordItem(long itemId) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"""
|
||||
SELECT *
|
||||
FROM balance_record
|
||||
LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
|
||||
WHERE h.item_id = ?""",
|
||||
List.of(itemId),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
|
||||
return new AccountHistoryItem(
|
||||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
rs.getLong("account_id"),
|
||||
AccountHistoryItemType.valueOf(rs.getString("type"))
|
||||
);
|
||||
}
|
||||
|
||||
private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
||||
return DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
|
||||
List.of(
|
||||
DbUtil.timestampFromUtcLDT(timestamp),
|
||||
accountId,
|
||||
type.name()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,8 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.EntityNotFoundException;
|
||||
import com.andrewlalis.perfin.data.*;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
|
@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
)
|
||||
);
|
||||
// Insert a history item indicating the creation of the account.
|
||||
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||
historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
|
||||
return accountId;
|
||||
});
|
||||
}
|
||||
|
@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
return DbUtil.findAll(
|
||||
conn,
|
||||
"""
|
||||
SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _
|
||||
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
|
||||
FROM account
|
||||
LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id
|
||||
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
|
||||
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
|
||||
WHERE NOT account.archived
|
||||
ORDER BY ahi.timestamp DESC, account.created_at DESC""",
|
||||
ORDER BY hi.timestamp DESC, account.created_at DESC""",
|
||||
JdbcAccountRepository::parseAccount
|
||||
);
|
||||
}
|
||||
|
@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
public void archive(long accountId) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
|
||||
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived.");
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||
historyRepo.addTextItem(historyId, "Account has been archived.");
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
public void unarchive(long accountId) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
|
||||
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived.");
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||
historyRepo.addTextItem(historyId, "Account has been unarchived.");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
|
@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
|||
}
|
||||
}
|
||||
// Add a history item entry.
|
||||
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||
historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
|
||||
return recordId;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -49,13 +49,23 @@ 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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountHistoryItemRepository getAccountHistoryItemRepository() {
|
||||
return new JdbcAccountHistoryItemRepository(getConnection());
|
||||
public HistoryRepository getHistoryRepository() {
|
||||
return new JdbcHistoryRepository(getConnection());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.DataSourceFactory;
|
||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.impl.migration.Migration;
|
||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
@ -14,16 +19,14 @@ import java.io.InputStream;
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.*;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Component that's responsible for obtaining a JDBC data source for a profile.
|
||||
*/
|
||||
public class JdbcDataSourceFactory {
|
||||
public class JdbcDataSourceFactory implements DataSourceFactory {
|
||||
private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
|
||||
|
||||
/**
|
||||
|
@ -32,7 +35,7 @@ public class JdbcDataSourceFactory {
|
|||
* the profile has a newer schema version, we'll exit and prompt the user
|
||||
* to update their app.
|
||||
*/
|
||||
public static final int SCHEMA_VERSION = 1;
|
||||
public static final int SCHEMA_VERSION = 3;
|
||||
|
||||
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
||||
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
||||
|
@ -59,6 +62,13 @@ public class JdbcDataSourceFactory {
|
|||
return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
|
||||
}
|
||||
|
||||
public SchemaStatus getSchemaStatus(String profileName) throws IOException {
|
||||
int existingSchemaVersion = getSchemaVersion(profileName);
|
||||
if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
|
||||
if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
|
||||
return SchemaStatus.INCOMPATIBLE;
|
||||
}
|
||||
|
||||
private void createNewDatabase(String profileName) throws ProfileLoadException {
|
||||
log.info("Creating new database for profile {}.", profileName);
|
||||
JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
|
||||
|
@ -69,6 +79,7 @@ public class JdbcDataSourceFactory {
|
|||
if (in == null) throw new IOException("Could not load database schema SQL file.");
|
||||
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
executeSqlScript(schemaStr, conn);
|
||||
insertDefaultData(conn);
|
||||
try {
|
||||
writeCurrentSchemaVersion(profileName);
|
||||
} catch (IOException e) {
|
||||
|
@ -89,6 +100,53 @@ public class JdbcDataSourceFactory {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts all default data into the database, using static content found in
|
||||
* various locations on the classpath.
|
||||
* @param conn The connection to use to insert data.
|
||||
* @throws IOException If resources couldn't be read.
|
||||
* @throws SQLException If SQL fails.
|
||||
*/
|
||||
public void insertDefaultData(Connection conn) throws IOException, SQLException {
|
||||
insertDefaultCategories(conn);
|
||||
}
|
||||
|
||||
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
|
||||
try (
|
||||
var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
|
||||
var stmt = conn.prepareStatement(
|
||||
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS
|
||||
)
|
||||
) {
|
||||
if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
|
||||
insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
|
||||
}
|
||||
}
|
||||
|
||||
private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
|
||||
for (JsonNode obj : categoriesArray) {
|
||||
String name = obj.get("name").asText();
|
||||
String colorHex = parentColorHex;
|
||||
if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
|
||||
if (parentId == null) {
|
||||
stmt.setNull(1, Types.BIGINT);
|
||||
} else {
|
||||
stmt.setLong(1, parentId);
|
||||
}
|
||||
stmt.setString(2, name);
|
||||
stmt.setString(3, colorHex.substring(1));
|
||||
int result = stmt.executeUpdate();
|
||||
if (result != 1) throw new SQLException("Failed to insert category.");
|
||||
long id = DbUtil.getGeneratedId(stmt);
|
||||
if (obj.hasNonNull("children") && obj.get("children").isArray()) {
|
||||
insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean testConnection(JdbcDataSource dataSource) {
|
||||
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
|
||||
return stmt.execute("SELECT 1;");
|
||||
|
@ -168,7 +226,7 @@ public class JdbcDataSourceFactory {
|
|||
return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
|
||||
}
|
||||
|
||||
private static int getSchemaVersion(String profileName) throws IOException {
|
||||
public int getSchemaVersion(String profileName) throws IOException {
|
||||
if (Files.exists(getSchemaVersionFile(profileName))) {
|
||||
try {
|
||||
return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
|
||||
@Override
|
||||
public long getOrCreateHistoryForAccount(long accountId) {
|
||||
return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getOrCreateHistoryForTransaction(long transactionId) {
|
||||
return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
|
||||
}
|
||||
|
||||
private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
|
||||
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
|
||||
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
|
||||
if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
|
||||
long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
|
||||
String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
|
||||
DbUtil.updateOne(conn, insertQuery, entityId, historyId);
|
||||
return historyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteHistoryForAccount(long accountId) {
|
||||
deleteHistoryForEntity(accountId, "history_account", "account_id");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteHistoryForTransaction(long transactionId) {
|
||||
deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
|
||||
}
|
||||
|
||||
private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
|
||||
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
|
||||
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
|
||||
if (optionalHistoryId.isPresent()) {
|
||||
long historyId = optionalHistoryId.get();
|
||||
DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
|
||||
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
|
||||
DbUtil.updateOne(
|
||||
conn,
|
||||
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
|
||||
itemId,
|
||||
description
|
||||
);
|
||||
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
|
||||
}
|
||||
|
||||
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
|
||||
return DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
|
||||
historyId,
|
||||
DbUtil.timestampFromUtcLDT(timestamp),
|
||||
type
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM history_item WHERE history_id = ?",
|
||||
pagination,
|
||||
List.of(historyId),
|
||||
JdbcHistoryRepository::parseItem
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"""
|
||||
SELECT *
|
||||
FROM history_item
|
||||
WHERE history_id = ? AND timestamp <= ?
|
||||
ORDER BY timestamp DESC""",
|
||||
List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
|
||||
JdbcHistoryRepository::parseItem
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
public static HistoryItem parseItem(ResultSet rs) throws SQLException {
|
||||
long id = rs.getLong(1);
|
||||
long historyId = rs.getLong(2);
|
||||
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
|
||||
String type = rs.getString(4);
|
||||
if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
|
||||
String description = DbUtil.findOne(
|
||||
rs.getStatement().getConnection(),
|
||||
"SELECT description FROM history_item_text WHERE id = ?",
|
||||
List.of(id),
|
||||
r -> r.getString(1)
|
||||
).orElseThrow();
|
||||
return new HistoryTextItem(id, historyId, timestamp, description);
|
||||
}
|
||||
throw new SQLException("Unknown history item type: " + type);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
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.ArrayList;
|
||||
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 update(long id, String name, Color color) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
TransactionCategory category = findById(id).orElseThrow();
|
||||
if (!category.getName().equals(name)) {
|
||||
DbUtil.updateOne(
|
||||
conn,
|
||||
"UPDATE transaction_category SET name = ? WHERE id = ?",
|
||||
name,
|
||||
id
|
||||
);
|
||||
}
|
||||
if (!category.getColor().equals(color)) {
|
||||
DbUtil.updateOne(
|
||||
conn,
|
||||
"UPDATE transaction_category SET color = ? WHERE id = ?",
|
||||
ColorUtil.toHex(color),
|
||||
id
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(long id) {
|
||||
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CategoryTreeNode> findTree() {
|
||||
List<TransactionCategory> rootCategories = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
|
||||
JdbcTransactionCategoryRepository::parseCategory
|
||||
);
|
||||
List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
|
||||
for (var category : rootCategories) {
|
||||
rootNodes.add(findTreeRecursive(category));
|
||||
}
|
||||
return rootNodes;
|
||||
}
|
||||
|
||||
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
|
||||
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
|
||||
List<TransactionCategory> childCategories = DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
|
||||
List.of(root.id),
|
||||
JdbcTransactionCategoryRepository::parseCategory
|
||||
);
|
||||
for (var childCategory : childCategories) {
|
||||
node.children().add(findTreeRecursive(childCategory));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
@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"))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl;
|
|||
|
||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.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.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 +29,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 +223,51 @@ 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 deleteTag(String name) {
|
||||
DbUtil.update(
|
||||
conn,
|
||||
"DELETE FROM transaction_tag WHERE name = ?",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countTagUsages(String name) {
|
||||
return DbUtil.count(
|
||||
conn,
|
||||
"""
|
||||
SELECT COUNT(transaction_id)
|
||||
FROM transaction_tag_join
|
||||
WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(long transactionId) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
|
@ -164,44 +285,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,10 +384,12 @@ 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));
|
||||
linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
|
||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||
long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
|
||||
historyRepo.addTextItem(historyId, updateMessageStr);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -226,16 +398,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 +405,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,102 @@
|
|||
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.Objects;
|
||||
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 update(long id, String name, String description) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
TransactionVendor vendor = findById(id).orElseThrow();
|
||||
if (!vendor.getName().equals(name)) {
|
||||
DbUtil.updateOne(
|
||||
conn,
|
||||
"UPDATE transaction_vendor SET name = ? WHERE id = ?",
|
||||
name,
|
||||
id
|
||||
);
|
||||
}
|
||||
if (!Objects.equals(vendor.getDescription(), description)) {
|
||||
DbUtil.updateOne(
|
||||
conn,
|
||||
"UPDATE transaction_vendor SET description = ? WHERE id = ?",
|
||||
description,
|
||||
id
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,10 +4,20 @@ import java.util.HashMap;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Utility class for defining and using all known migrations.
|
||||
*/
|
||||
public class Migrations {
|
||||
/**
|
||||
* Gets a list of migrations, as a map with the key being the version to
|
||||
* migrate from. For example, a migration that takes us from version 42 to
|
||||
* 43 would exist in the map with key 42.
|
||||
* @return The map of all migrations.
|
||||
*/
|
||||
public static Map<Integer, Migration> getMigrations() {
|
||||
final Map<Integer, Migration> migrations = new HashMap<>();
|
||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
|
||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
||||
return migrations;
|
||||
}
|
||||
|
||||
|
@ -25,4 +35,14 @@ public class Migrations {
|
|||
}
|
||||
return selectedMigration;
|
||||
}
|
||||
|
||||
public static Map<Integer, String> getSchemaVersionCompatibility() {
|
||||
final Map<Integer, String> compatibilities = new HashMap<>();
|
||||
compatibilities.put(1, "1.4.0");
|
||||
return compatibilities;
|
||||
}
|
||||
|
||||
public static String getLatestCompatibleVersion(int schemaVersion) {
|
||||
return getSchemaVersionCompatibility().get(schemaVersion);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package com.andrewlalis.perfin.data.search;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An entity searcher will search for entities matching a list of filters.
|
||||
* @param <T> The entity type to search over.
|
||||
*/
|
||||
public interface EntitySearcher<T> {
|
||||
/**
|
||||
* Gets a page of results that match the given filters.
|
||||
* @param pageRequest The page request.
|
||||
* @param filters The filters to apply.
|
||||
* @return A page of results.
|
||||
*/
|
||||
Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
|
||||
|
||||
/**
|
||||
* Gets the number of results that would be returned for a given set of
|
||||
* filters.
|
||||
* @param filters The filters to apply.
|
||||
* @return The number of entities that match.
|
||||
*/
|
||||
long resultCount(List<SearchFilter> filters);
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package com.andrewlalis.perfin.data.search;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.Page;
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
import com.andrewlalis.perfin.data.util.ResultSetMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
|
||||
|
||||
private final Connection conn;
|
||||
private final String countExpression;
|
||||
private final String selectExpression;
|
||||
private final ResultSetMapper<T> resultSetMapper;
|
||||
|
||||
public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
|
||||
this.conn = conn;
|
||||
this.countExpression = countExpression;
|
||||
this.selectExpression = selectExpression;
|
||||
this.resultSetMapper = resultSetMapper;
|
||||
}
|
||||
|
||||
private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
|
||||
if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
List<Pair<Integer, Object>> args = new ArrayList<>();
|
||||
for (var filter : filters) {
|
||||
args.addAll(filter.args());
|
||||
for (var joinClause : filter.joinClauses()) {
|
||||
sb.append(joinClause).append('\n');
|
||||
}
|
||||
}
|
||||
sb.append("WHERE\n");
|
||||
for (int i = 0; i < filters.size(); i++) {
|
||||
sb.append(filters.get(i).whereClause());
|
||||
if (i < filters.size() - 1) {
|
||||
sb.append(" AND");
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
return new Pair<>(sb.toString(), args);
|
||||
}
|
||||
|
||||
private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
|
||||
for (int i = 1; i <= args.size(); i++) {
|
||||
Pair<Integer, Object> arg = args.get(i - 1);
|
||||
if (arg.second() == null) {
|
||||
stmt.setNull(i, arg.first());
|
||||
} else {
|
||||
stmt.setObject(i, arg.second(), arg.first());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
|
||||
var baseQueryAndArgs = buildSearchQuery(filters);
|
||||
StringBuilder sqlBuilder = new StringBuilder(selectExpression);
|
||||
if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
|
||||
sqlBuilder.append('\n').append(baseQueryAndArgs.first());
|
||||
}
|
||||
String pagingSql = pageRequest.toSQL();
|
||||
if (pagingSql != null && !pagingSql.isBlank()) {
|
||||
sqlBuilder.append('\n').append(pagingSql);
|
||||
}
|
||||
String sql = sqlBuilder.toString();
|
||||
logger.debug(
|
||||
"Searching with query:\n{}\nWith arguments: {}",
|
||||
sql,
|
||||
baseQueryAndArgs.second().stream()
|
||||
.map(Pair::second)
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.joining(", "))
|
||||
);
|
||||
try (var stmt = conn.prepareStatement(sql)) {
|
||||
applyArgs(stmt, baseQueryAndArgs.second());
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
List<T> results = new ArrayList<>(pageRequest.size());
|
||||
while (rs.next() && results.size() < pageRequest.size()) {
|
||||
results.add(resultSetMapper.map(rs));
|
||||
}
|
||||
return new Page<>(results, pageRequest);
|
||||
} catch (SQLException e) {
|
||||
logger.error("Search failed.", e);
|
||||
return new Page<>(Collections.emptyList(), pageRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long resultCount(List<SearchFilter> filters) {
|
||||
var baseQueryAndArgs = buildSearchQuery(filters);
|
||||
String sql = countExpression + "\n" + baseQueryAndArgs.first();
|
||||
try (var stmt = conn.prepareStatement(sql)) {
|
||||
applyArgs(stmt, baseQueryAndArgs.second());
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
if (!rs.next()) throw new SQLException("No count result.");
|
||||
return rs.getLong(1);
|
||||
} catch (SQLException e) {
|
||||
logger.error("Failed to get search result count.", e);
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.andrewlalis.perfin.data.search;
|
||||
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Currency;
|
||||
|
||||
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
|
||||
public JdbcTransactionSearcher(Connection conn) {
|
||||
super(
|
||||
conn,
|
||||
"SELECT COUNT(transaction.id) FROM transaction",
|
||||
"SELECT transaction.* FROM transaction",
|
||||
JdbcTransactionSearcher::parseResultSet
|
||||
);
|
||||
}
|
||||
|
||||
private static Transaction parseResultSet(ResultSet rs) throws SQLException {
|
||||
long id = rs.getLong(1);
|
||||
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
|
||||
BigDecimal amount = rs.getBigDecimal(3);
|
||||
Currency currency = Currency.getInstance(rs.getString(4));
|
||||
String description = rs.getString(5);
|
||||
Long vendorId = rs.getLong(6);
|
||||
if (rs.wasNull()) vendorId = null;
|
||||
Long categoryId = rs.getLong(7);
|
||||
if (rs.wasNull()) categoryId = null;
|
||||
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package com.andrewlalis.perfin.data.search;
|
||||
|
||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
|
||||
import java.sql.Types;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public interface SearchFilter {
|
||||
String whereClause();
|
||||
List<Pair<Integer, Object>> args();
|
||||
default List<String> joinClauses() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
|
||||
|
||||
class Builder {
|
||||
private String whereClause;
|
||||
private List<Pair<Integer, Object>> args = new ArrayList<>();
|
||||
private List<String> joinClauses = new ArrayList<>();
|
||||
|
||||
public Builder where(String clause) {
|
||||
this.whereClause = clause;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withArg(int sqlType, Object value) {
|
||||
args.add(new Pair<>(sqlType, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder withArg(int value) {
|
||||
return withArg(Types.INTEGER, value);
|
||||
}
|
||||
|
||||
public Builder withArg(long value) {
|
||||
return withArg(Types.BIGINT, value);
|
||||
}
|
||||
|
||||
public Builder withArg(String value) {
|
||||
return withArg(Types.VARCHAR, value);
|
||||
}
|
||||
|
||||
public Builder withArg(LocalDateTime utcTimestamp) {
|
||||
return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||
}
|
||||
|
||||
public Builder withJoin(String joinClause) {
|
||||
joinClauses.add(joinClause);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SearchFilter build() {
|
||||
return new Impl(whereClause, args, joinClauses);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -31,6 +31,15 @@ public final class DbUtil {
|
|||
setArgs(stmt, List.of(args));
|
||||
}
|
||||
|
||||
public static long getGeneratedId(PreparedStatement stmt) {
|
||||
try (ResultSet rs = stmt.getGeneratedKeys()) {
|
||||
if (!rs.next()) throw new SQLException("No generated keys available.");
|
||||
return rs.getLong(1);
|
||||
} catch (SQLException e) {
|
||||
throw new UncheckedSqlException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
setArgs(stmt, args);
|
||||
|
@ -58,6 +67,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 +102,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 +116,25 @@ 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();
|
||||
return rs.getLong(1);
|
||||
return getGeneratedId(stmt);
|
||||
} 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));
|
||||
}
|
||||
|
@ -132,7 +162,9 @@ public final class DbUtil {
|
|||
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
|
||||
try {
|
||||
conn.setAutoCommit(false);
|
||||
return supplier.offer();
|
||||
T result = supplier.offer();
|
||||
conn.commit();
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
conn.rollback();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.andrewlalis.perfin.data.util;
|
||||
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
@ -103,4 +104,14 @@ public class FileUtil {
|
|||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyResourceFile(String resource, Path dest) throws IOException {
|
||||
try (
|
||||
var in = Profile.class.getResourceAsStream(resource);
|
||||
var out = Files.newOutputStream(dest)
|
||||
) {
|
||||
if (in == null) throw new IOException("Could not load resource " + resource);
|
||||
in.transferTo(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
|
|||
|
||||
import com.andrewlalis.perfin.PerfinApp;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.HashSet;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
|
@ -33,35 +28,22 @@ import java.util.function.Consumer;
|
|||
* class maintains a static <em>current</em> profile that can be loaded and
|
||||
* unloaded.
|
||||
* </p>
|
||||
*
|
||||
* @param name The name of the profile.
|
||||
* @param settings The profile's settings.
|
||||
* @param dataSource The profile's data source.
|
||||
*/
|
||||
public class Profile {
|
||||
public record Profile(String name, Properties settings, DataSource dataSource) {
|
||||
private static final Logger log = LoggerFactory.getLogger(Profile.class);
|
||||
|
||||
private static Profile current;
|
||||
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
|
||||
private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
|
||||
|
||||
private final String name;
|
||||
private final Properties settings;
|
||||
private final DataSource dataSource;
|
||||
|
||||
private Profile(String name, Properties settings, DataSource dataSource) {
|
||||
this.name = name;
|
||||
this.settings = settings;
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Properties getSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public DataSource getDataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
public static Path getDir(String name) {
|
||||
return PerfinApp.APP_DIR.resolve(name);
|
||||
}
|
||||
|
@ -78,89 +60,23 @@ public class Profile {
|
|||
return current;
|
||||
}
|
||||
|
||||
public static void setCurrent(Profile profile) {
|
||||
current = profile;
|
||||
for (var ref : currentProfileListeners) {
|
||||
Consumer<Profile> consumer = ref.get();
|
||||
if (consumer != null) {
|
||||
consumer.accept(profile);
|
||||
}
|
||||
}
|
||||
currentProfileListeners.removeIf(ref -> ref.get() == null);
|
||||
log.debug("Current profile set to {}.", current.name());
|
||||
}
|
||||
|
||||
public static void whenLoaded(Consumer<Profile> consumer) {
|
||||
if (current != null) {
|
||||
consumer.accept(current);
|
||||
} else {
|
||||
profileLoadListeners.add(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<String> getAvailableProfiles() {
|
||||
try (var files = Files.list(PerfinApp.APP_DIR)) {
|
||||
return files.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.sorted().toList();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to get a list of available profiles.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getLastProfile() {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
if (Files.exists(lastProfileFile)) {
|
||||
try {
|
||||
String s = Files.readString(lastProfileFile).strip().toLowerCase();
|
||||
if (!s.isBlank()) return s;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read " + lastProfileFile, e);
|
||||
}
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
public static void saveLastProfile(String name) {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
try {
|
||||
Files.writeString(lastProfileFile, name);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write " + lastProfileFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void loadLast() throws ProfileLoadException {
|
||||
load(getLastProfile());
|
||||
}
|
||||
|
||||
public static void load(String name) throws ProfileLoadException {
|
||||
if (Files.notExists(getDir(name))) {
|
||||
try {
|
||||
initProfileDir(name);
|
||||
} catch (IOException e) {
|
||||
FileUtil.deleteIfPossible(getDir(name));
|
||||
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
|
||||
}
|
||||
}
|
||||
Properties settings = new Properties();
|
||||
try (var in = Files.newInputStream(getSettingsFile(name))) {
|
||||
settings.load(in);
|
||||
} catch (IOException e) {
|
||||
throw new ProfileLoadException("Failed to load profile settings.", e);
|
||||
}
|
||||
current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
|
||||
saveLastProfile(current.getName());
|
||||
for (var c : profileLoadListeners) {
|
||||
c.accept(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static void initProfileDir(String name) throws IOException {
|
||||
Files.createDirectory(getDir(name));
|
||||
copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
|
||||
copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
|
||||
Files.createDirectory(getContentDir(name));
|
||||
copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
|
||||
}
|
||||
|
||||
private static void copyResourceFile(String resource, Path dest) throws IOException {
|
||||
try (
|
||||
var in = Profile.class.getResourceAsStream(resource);
|
||||
var out = Files.newOutputStream(dest)
|
||||
) {
|
||||
if (in == null) throw new IOException("Could not load resource " + resource);
|
||||
in.transferTo(out);
|
||||
}
|
||||
currentProfileListeners.add(new WeakReference<>(consumer));
|
||||
}
|
||||
|
||||
public static boolean validateName(String name) {
|
||||
|
@ -168,9 +84,4 @@ public class Profile {
|
|||
name.matches("\\w+") &&
|
||||
name.toLowerCase().equals(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import com.andrewlalis.perfin.PerfinApp;
|
||||
import com.andrewlalis.perfin.control.Popups;
|
||||
import com.andrewlalis.perfin.data.DataSourceFactory;
|
||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import javafx.stage.Window;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
|
||||
|
||||
/**
|
||||
* Component responsible for loading a profile from storage, as well as some
|
||||
* other basic tasks concerning the set of stored profiles.
|
||||
*/
|
||||
public class ProfileLoader {
|
||||
private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
|
||||
|
||||
private final Window window;
|
||||
private final DataSourceFactory dataSourceFactory;
|
||||
|
||||
public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
|
||||
this.window = window;
|
||||
this.dataSourceFactory = dataSourceFactory;
|
||||
}
|
||||
|
||||
public Profile load(String name) throws ProfileLoadException {
|
||||
if (Files.notExists(Profile.getDir(name))) {
|
||||
try {
|
||||
initProfileDir(name);
|
||||
} catch (IOException e) {
|
||||
FileUtil.deleteIfPossible(Profile.getDir(name));
|
||||
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
|
||||
}
|
||||
}
|
||||
Properties settings = new Properties();
|
||||
try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
|
||||
settings.load(in);
|
||||
} catch (IOException e) {
|
||||
throw new ProfileLoadException("Failed to load profile settings.", e);
|
||||
}
|
||||
try {
|
||||
DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
|
||||
if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
|
||||
boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
|
||||
if (!confirm) {
|
||||
int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
|
||||
String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
|
||||
Popups.message(
|
||||
window,
|
||||
"The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
|
||||
);
|
||||
throw new ProfileLoadException("User rejected the migration.");
|
||||
}
|
||||
} else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
|
||||
Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
|
||||
throw new ProfileLoadException("Incompatible schema version.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ProfileLoadException("Failed to get profile's schema status.", e);
|
||||
}
|
||||
return new Profile(name, settings, dataSourceFactory.getDataSource(name));
|
||||
}
|
||||
|
||||
public static List<String> getAvailableProfiles() {
|
||||
try (var files = Files.list(PerfinApp.APP_DIR)) {
|
||||
return files.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.sorted().toList();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to get a list of available profiles.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getLastProfile() {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
if (Files.exists(lastProfileFile)) {
|
||||
try {
|
||||
String s = Files.readString(lastProfileFile).strip().toLowerCase();
|
||||
if (!s.isBlank()) return s;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read " + lastProfileFile, e);
|
||||
}
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
public static void saveLastProfile(String name) {
|
||||
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||
try {
|
||||
Files.writeString(lastProfileFile, name);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write " + lastProfileFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
private static void initProfileDir(String name) throws IOException {
|
||||
Files.createDirectory(Profile.getDir(name));
|
||||
copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
|
||||
copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
|
||||
Files.createDirectory(Profile.getContentDir(name));
|
||||
copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
import com.andrewlalis.perfin.model.IdEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The base class representing account history items, a read-only record of an
|
||||
* account's data and changes over time. The type of history item determines
|
||||
* what exactly it means, and could be something like an account entry, balance
|
||||
* record, or modifications to the account's properties.
|
||||
*/
|
||||
public class AccountHistoryItem extends IdEntity {
|
||||
private final LocalDateTime timestamp;
|
||||
private final long accountId;
|
||||
private final AccountHistoryItemType type;
|
||||
|
||||
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
|
||||
super(id);
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public AccountHistoryItemType getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
public enum AccountHistoryItemType {
|
||||
TEXT,
|
||||
ACCOUNT_ENTRY,
|
||||
BALANCE_RECORD
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
import com.andrewlalis.perfin.model.IdEntity;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Represents a single polymorphic history item. The item's "type" attribute
|
||||
* tells where to find additional type-specific data.
|
||||
*/
|
||||
public abstract class HistoryItem extends IdEntity {
|
||||
public static final String TYPE_TEXT = "TEXT";
|
||||
|
||||
private final long historyId;
|
||||
private final LocalDateTime timestamp;
|
||||
private final String type;
|
||||
|
||||
public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
|
||||
super(id);
|
||||
this.historyId = historyId;
|
||||
this.timestamp = timestamp;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public long getHistoryId() {
|
||||
return historyId;
|
||||
}
|
||||
|
||||
public LocalDateTime getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package com.andrewlalis.perfin.model.history;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class HistoryTextItem extends HistoryItem {
|
||||
private final String description;
|
||||
|
||||
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
|
||||
super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
package com.andrewlalis.perfin.view;
|
||||
|
||||
import javafx.beans.WeakListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
|
@ -86,4 +88,9 @@ public class BindingUtil {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
|
||||
node.managedProperty().bind(node.visibleProperty());
|
||||
node.visibleProperty().bind(value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import javafx.stage.Stage;
|
|||
import javafx.stage.StageStyle;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
|
@ -17,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);
|
||||
|
@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
|||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all tasks sequentially, invoking each one on the JavaFX main thread,
|
||||
* and quitting if there's any exception thrown.
|
||||
*/
|
||||
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 {
|
||||
task.accept(this);
|
||||
Thread.sleep(500);
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
task.accept(this);
|
||||
future.complete(null);
|
||||
} catch (Exception e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
future.join();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
|
||||
public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
|
||||
super(item);
|
||||
AccountEntry entry = repo.getAccountEntryItem(item.id);
|
||||
if (entry == null) {
|
||||
setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
|
||||
return;
|
||||
}
|
||||
|
||||
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
|
||||
Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
|
||||
transactionLink.setOnAction(event -> router.navigate(
|
||||
"transactions",
|
||||
new TransactionsViewController.RouteContext(entry.getTransactionId())
|
||||
));
|
||||
var text = new TextFlow(
|
||||
transactionLink,
|
||||
new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
|
||||
amountText
|
||||
);
|
||||
setCenter(text);
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.control.AccountViewController;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
|
||||
public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
|
||||
super(item);
|
||||
BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
|
||||
if (balanceRecord == null) {
|
||||
setCenter(new TextFlow(new Text("Deleted balance record was added.")));
|
||||
return;
|
||||
}
|
||||
|
||||
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
|
||||
var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
|
||||
setCenter(text);
|
||||
|
||||
Hyperlink viewLink = new Hyperlink("View this balance record");
|
||||
viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
|
||||
setBottom(viewLink);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.control.AccountViewController;
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
|
||||
|
@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane;
|
|||
* A tile that shows a brief bit of information about an account history item.
|
||||
*/
|
||||
public abstract class AccountHistoryItemTile extends BorderPane {
|
||||
public AccountHistoryItemTile(AccountHistoryItem item) {
|
||||
public AccountHistoryItemTile(HistoryItem item) {
|
||||
getStyleClass().add("tile");
|
||||
|
||||
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
|
||||
|
@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane {
|
|||
}
|
||||
|
||||
public static AccountHistoryItemTile forItem(
|
||||
AccountHistoryItem item,
|
||||
AccountHistoryItemRepository repo,
|
||||
AccountViewController controller
|
||||
HistoryItem item
|
||||
) {
|
||||
return switch (item.getType()) {
|
||||
case TEXT -> new AccountHistoryTextTile(item, repo);
|
||||
case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo);
|
||||
case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller);
|
||||
};
|
||||
if (item instanceof HistoryTextItem t) {
|
||||
return new AccountHistoryTextTile(t);
|
||||
}
|
||||
throw new RuntimeException("Unsupported history item type: " + item.getType());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
|
||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
|
||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextFlow;
|
||||
|
||||
public class AccountHistoryTextTile extends AccountHistoryItemTile {
|
||||
public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
|
||||
public AccountHistoryTextTile(HistoryTextItem item) {
|
||||
super(item);
|
||||
String text = repo.getTextItem(item.id);
|
||||
setCenter(new TextFlow(new Text(text)));
|
||||
setCenter(new TextFlow(new Text(item.getDescription())));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
|||
showBalanceProperty.set(value);
|
||||
}
|
||||
|
||||
private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> {
|
||||
private final BooleanProperty showBalanceProp;
|
||||
|
||||
private CellFactory(BooleanProperty showBalanceProp) {
|
||||
this.showBalanceProp = showBalanceProp;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple cell factory that just returns instances of {@link AccountListCell}.
|
||||
* @param showBalanceProp Whether to show the account's balance.
|
||||
*/
|
||||
private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
|
||||
@Override
|
||||
public ListCell<Account> call(ListView<Account> param) {
|
||||
return new AccountListCell(showBalanceProp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list cell implementation which shows an account's name, and optionally,
|
||||
* its current derived balance underneath.
|
||||
*/
|
||||
private static class AccountListCell extends ListCell<Account> {
|
||||
private final BooleanProperty showBalanceProp;
|
||||
private final Label nameLabel = new Label();
|
||||
|
@ -110,7 +112,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
|||
|
||||
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
||||
if (showBalanceProp.get()) {
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(item.id);
|
||||
Platform.runLater(() -> {
|
||||
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
||||
|
|
|
@ -81,7 +81,7 @@ public class AccountTile extends BorderPane {
|
|||
Label balanceLabel = new Label("Computing balance...");
|
||||
balanceLabel.getStyleClass().addAll("mono-font");
|
||||
balanceLabel.setDisable(true);
|
||||
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
||||
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
|
||||
Platform.runLater(() -> {
|
||||
|
|
|
@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
|
|||
boolean showDocIcon = true;
|
||||
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
|
||||
if (imageTypes.contains(attachment.getContentType())) {
|
||||
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
|
||||
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().name())))) {
|
||||
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
|
||||
contentContainer.setCenter(new ImageView(img));
|
||||
showDocIcon = false;
|
||||
|
@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane {
|
|||
this.setCenter(stackPane);
|
||||
this.setOnMouseClicked(event -> {
|
||||
if (this.isHover()) {
|
||||
Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName()));
|
||||
Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().name()));
|
||||
PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.shape.Circle;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class CategorySelectionBox extends ComboBox<TransactionCategory> {
|
||||
private final Map<TransactionCategory, Integer> categoryIndentationLevels = new HashMap<>();
|
||||
|
||||
public CategorySelectionBox() {
|
||||
setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
|
||||
setButtonCell(new CategoryListCell(null));
|
||||
}
|
||||
|
||||
public void loadCategories(List<TransactionCategoryRepository.CategoryTreeNode> treeNodes) {
|
||||
categoryIndentationLevels.clear();
|
||||
getItems().clear();
|
||||
populateCategories(treeNodes, 0);
|
||||
getItems().add(null);
|
||||
}
|
||||
|
||||
private void populateCategories(
|
||||
List<TransactionCategoryRepository.CategoryTreeNode> treeNodes,
|
||||
int depth
|
||||
) {
|
||||
for (var node : treeNodes) {
|
||||
getItems().add(node.category());
|
||||
categoryIndentationLevels.put(node.category(), depth);
|
||||
populateCategories(node.children(), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public void select(TransactionCategory category) {
|
||||
setButtonCell(new CategoryListCell(null));
|
||||
getSelectionModel().select(category);
|
||||
}
|
||||
|
||||
private static class CategoryListCell extends ListCell<TransactionCategory> {
|
||||
private final Label nameLabel = new Label();
|
||||
private final Circle colorIndicator = new Circle(8);
|
||||
private final Map<TransactionCategory, Integer> categoryIndentationLevels;
|
||||
|
||||
public CategoryListCell(Map<TransactionCategory, Integer> categoryIndentationLevels) {
|
||||
this.categoryIndentationLevels = categoryIndentationLevels;
|
||||
nameLabel.getStyleClass().add("normal-color-text-fill");
|
||||
colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
|
||||
HBox container = new HBox(colorIndicator, nameLabel);
|
||||
container.getStyleClass().add("std-spacing");
|
||||
setGraphic(container);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(TransactionCategory item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (item == null || empty) {
|
||||
nameLabel.setText("None");
|
||||
colorIndicator.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
nameLabel.setText(item.getName());
|
||||
if (categoryIndentationLevels != null) {
|
||||
HBox.setMargin(
|
||||
colorIndicator,
|
||||
new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
|
||||
);
|
||||
}
|
||||
colorIndicator.setVisible(true);
|
||||
colorIndicator.setFill(item.getColor());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.control.EditCategoryController;
|
||||
import com.andrewlalis.perfin.control.Popups;
|
||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Circle;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class CategoryTile extends VBox {
|
||||
public CategoryTile(
|
||||
TransactionCategoryRepository.CategoryTreeNode treeNode,
|
||||
Runnable categoriesRefresh
|
||||
) {
|
||||
this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor");
|
||||
this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
|
||||
this.setOnMouseClicked(event -> {
|
||||
event.consume();
|
||||
router.navigate(
|
||||
"edit-category",
|
||||
new EditCategoryController.CategoryRouteContext(treeNode.category())
|
||||
);
|
||||
});
|
||||
|
||||
BorderPane borderPane = new BorderPane();
|
||||
borderPane.getStyleClass().addAll("std-padding");
|
||||
Label nameLabel = new Label(treeNode.category().getName());
|
||||
nameLabel.getStyleClass().addAll("bold-text");
|
||||
Circle colorCircle = new Circle(10, treeNode.category().getColor());
|
||||
HBox contentBox = new HBox(colorCircle, nameLabel);
|
||||
contentBox.getStyleClass().addAll("std-spacing");
|
||||
borderPane.setLeft(contentBox);
|
||||
|
||||
Button addChildButton = new Button("Add Subcategory");
|
||||
addChildButton.setOnAction(event -> router.navigate(
|
||||
"edit-category",
|
||||
new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
|
||||
));
|
||||
Button removeButton = new Button("Remove");
|
||||
removeButton.setOnAction(event -> {
|
||||
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
|
||||
if (confirm) {
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionCategoryRepository.class,
|
||||
repo -> repo.deleteById(treeNode.category().id)
|
||||
);
|
||||
categoriesRefresh.run();
|
||||
}
|
||||
});
|
||||
HBox buttonsBox = new HBox(addChildButton, removeButton);
|
||||
buttonsBox.getStyleClass().addAll("std-spacing");
|
||||
borderPane.setRight(buttonsBox);
|
||||
|
||||
this.getChildren().add(borderPane);
|
||||
for (var child : treeNode.children()) {
|
||||
this.getChildren().add(new CategoryTile(child, categoriesRefresh));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane {
|
|||
}
|
||||
|
||||
private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
|
||||
return Profile.getCurrent().getDataSource().mapRepoAsync(
|
||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.findLinkedAccounts(transaction.id)
|
||||
);
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package com.andrewlalis.perfin.view.component;
|
||||
|
||||
import com.andrewlalis.perfin.control.Popups;
|
||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
public class VendorTile extends BorderPane {
|
||||
public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
|
||||
this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
|
||||
this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
|
||||
|
||||
Label nameLabel = new Label(vendor.getName());
|
||||
nameLabel.getStyleClass().addAll("bold-text");
|
||||
Label descriptionLabel = new Label(vendor.getDescription());
|
||||
descriptionLabel.setWrapText(true);
|
||||
VBox contentVBox = new VBox(nameLabel, descriptionLabel);
|
||||
contentVBox.getStyleClass().addAll("std-spacing");
|
||||
this.setCenter(contentVBox);
|
||||
BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
|
||||
|
||||
this.setRight(getRemoveButton(vendor, vendorRefresh));
|
||||
}
|
||||
|
||||
private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
|
||||
Button removeButton = new Button("Remove");
|
||||
removeButton.setOnAction(event -> {
|
||||
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone.");
|
||||
if (confirm) {
|
||||
Profile.getCurrent().dataSource().useRepo(
|
||||
TransactionVendorRepository.class,
|
||||
repo -> repo.deleteById(transactionVendor.id)
|
||||
);
|
||||
vendorRefresh.run();
|
||||
}
|
||||
});
|
||||
return removeButton;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.andrewlalis.perfin.view.component.validation;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface AsyncValidationFunction<T> {
|
||||
CompletableFuture<ValidationResult> validate(T input);
|
||||
}
|
|
@ -1,24 +1,40 @@
|
|||
package com.andrewlalis.perfin.view.component.validation;
|
||||
|
||||
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Fluent interface for applying a validator to one or more controls.
|
||||
* @param <T> The value type.
|
||||
*/
|
||||
public class ValidationApplier<T> {
|
||||
private final ValidationFunction<T> validator;
|
||||
private final AsyncValidationFunction<T> validator;
|
||||
private ValidationDecorator decorator = new FieldSubtextDecorator();
|
||||
private boolean validateInitially = false;
|
||||
|
||||
public ValidationApplier(ValidationFunction<T> validator) {
|
||||
this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
|
||||
}
|
||||
|
||||
public ValidationApplier(AsyncValidationFunction<T> validator) {
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
public static <T> ValidationApplier<T> of(ValidationFunction<T> validator) {
|
||||
return new ValidationApplier<>(validator);
|
||||
}
|
||||
|
||||
public static <T> ValidationApplier<T> ofAsync(AsyncValidationFunction<T> validator) {
|
||||
return new ValidationApplier<>(validator);
|
||||
}
|
||||
|
||||
public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
|
||||
this.decorator = decorator;
|
||||
return this;
|
||||
|
@ -29,24 +45,47 @@ public class ValidationApplier<T> {
|
|||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the configured validator and decorator to a node, so that when
|
||||
* the node's specified valueProperty changes, the validator will be called
|
||||
* and if the new value is invalid, the decorator will update the UI to
|
||||
* show the message(s) to the user.
|
||||
* @param node The node to attach to.
|
||||
* @param valueProperty The property to listen for changes and validate on.
|
||||
* @param triggerProperties Additional properties that, when changed, can
|
||||
* trigger validation.
|
||||
* @return A boolean expression that tells whether the given valueProperty
|
||||
* is valid at any given time.
|
||||
*/
|
||||
public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
|
||||
BooleanExpression validProperty = BooleanExpression.booleanExpression(
|
||||
valueProperty.map(value -> validator.validate(value).isValid())
|
||||
);
|
||||
final SimpleBooleanProperty validProperty = new SimpleBooleanProperty();
|
||||
valueProperty.addListener((observable, oldValue, newValue) -> {
|
||||
ValidationResult result = validator.validate(newValue);
|
||||
decorator.decorate(node, result);
|
||||
validProperty.set(false); // Always set valid to false before we start validation.
|
||||
validator.validate(newValue)
|
||||
.thenAccept(result -> Platform.runLater(() -> {
|
||||
validProperty.set(result.isValid());
|
||||
decorator.decorate(node, result);
|
||||
}));
|
||||
});
|
||||
for (Property<?> influencingProperty : triggerProperties) {
|
||||
influencingProperty.addListener((observable, oldValue, newValue) -> {
|
||||
ValidationResult result = validator.validate(valueProperty.getValue());
|
||||
decorator.decorate(node, result);
|
||||
validProperty.set(false); // Always set valid to false before we start validation.
|
||||
validator.validate(valueProperty.getValue())
|
||||
.thenAccept(result -> Platform.runLater(() -> {
|
||||
validProperty.set(result.isValid());
|
||||
decorator.decorate(node, result);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (validateInitially) {
|
||||
// Call the decorator once to perform validation right away.
|
||||
decorator.decorate(node, validator.validate(valueProperty.getValue()));
|
||||
validProperty.set(false); // Always set valid to false before we start validation.
|
||||
validator.validate(valueProperty.getValue())
|
||||
.thenAccept(result -> Platform.runLater(() -> {
|
||||
validProperty.set(result.isValid());
|
||||
decorator.decorate(node, result);
|
||||
}));
|
||||
}
|
||||
return validProperty;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.andrewlalis.perfin.view.component.validation.ValidationDecorator;
|
|||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.slf4j.Logger;
|
||||
|
@ -55,6 +56,9 @@ public class FieldSubtextDecorator implements ValidationDecorator {
|
|||
errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
|
||||
errorLabel.setWrapText(true);
|
||||
VBox validationContainer = new VBox(node, errorLabel);
|
||||
if (trueParent instanceof HBox) {
|
||||
HBox.setHgrow(validationContainer, HBox.getHgrow(node));
|
||||
}
|
||||
validationContainer.setUserData(WRAP_KEY);
|
||||
trueParent.getChildren().add(idx, validationContainer);
|
||||
return errorLabel;
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
package com.andrewlalis.perfin.view.component.validation.validators;
|
||||
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
|
||||
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
|
@ -12,32 +16,73 @@ import java.util.function.Function;
|
|||
* determine if it's valid. If invalid, a message is added.
|
||||
* @param <T> The value type.
|
||||
*/
|
||||
public class PredicateValidator<T> implements ValidationFunction<T> {
|
||||
private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {}
|
||||
public class PredicateValidator<T> implements AsyncValidationFunction<T> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
|
||||
|
||||
private record ValidationStep<T>(Function<T, CompletableFuture<Boolean>> predicate, String message, boolean terminal) {}
|
||||
|
||||
private final List<ValidationStep<T>> steps = new ArrayList<>();
|
||||
|
||||
public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
|
||||
steps.add(new ValidationStep<>(predicate, errorMessage, false));
|
||||
private PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage, boolean terminal) {
|
||||
steps.add(new ValidationStep<>(
|
||||
v -> CompletableFuture.completedFuture(predicate.apply(v)),
|
||||
errorMessage,
|
||||
terminal
|
||||
));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
|
||||
steps.add(new ValidationStep<>(predicate, errorMessage, true));
|
||||
private PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage, boolean terminal) {
|
||||
steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
|
||||
return addPredicate(predicate, errorMessage, false);
|
||||
}
|
||||
|
||||
public PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
|
||||
return addAsyncPredicate(asyncPredicate, errorMessage, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a terminal predicate, that is, if the given boolean function
|
||||
* evaluates to false, then no further predicates are evaluated.
|
||||
* @param predicate The predicate function.
|
||||
* @param errorMessage The error message to display if the predicate
|
||||
* evaluates to false for a given value.
|
||||
* @return A reference to the validator, for method chaining.
|
||||
*/
|
||||
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
|
||||
return addPredicate(predicate, errorMessage, true);
|
||||
}
|
||||
|
||||
public PredicateValidator<T> addTerminalAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
|
||||
return addAsyncPredicate(asyncPredicate, errorMessage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ValidationResult validate(T input) {
|
||||
List<String> messages = new ArrayList<>();
|
||||
for (var step : steps) {
|
||||
if (!step.predicate().apply(input)) {
|
||||
messages.add(step.message());
|
||||
if (step.terminal()) {
|
||||
return new ValidationResult(messages);
|
||||
public CompletableFuture<ValidationResult> validate(T input) {
|
||||
CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
List<String> messages = new ArrayList<>();
|
||||
for (var step : steps) {
|
||||
try {
|
||||
boolean success = step.predicate().apply(input).get();
|
||||
if (!success) {
|
||||
messages.add(step.message());
|
||||
if (step.terminal()) {
|
||||
cf.complete(new ValidationResult(messages));
|
||||
return; // Exit if this is a terminal step and it failed.
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
logger.error("Applying a predicate to input failed.", e);
|
||||
cf.completeExceptionally(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ValidationResult(messages);
|
||||
cf.complete(new ValidationResult(messages));
|
||||
});
|
||||
return cf;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,4 +19,5 @@ module com.andrewlalis.perfin {
|
|||
opens com.andrewlalis.perfin.view to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.view.component to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
|
||||
exports com.andrewlalis.perfin.model.history to javafx.graphics;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.CategoriesViewController"
|
||||
>
|
||||
<top>
|
||||
<Label text="Transaction Categories" styleClass="large-font,bold-text,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<StyledText maxWidth="500" styleClass="std-padding">
|
||||
Categories are used to group your transactions based on their
|
||||
purpose. It's helpful to categorize transactions in order to get
|
||||
a better view of your spending habits, and it makes it easier to
|
||||
lookup transactions later.
|
||||
</StyledText>
|
||||
<HBox styleClass="std-padding, std-spacing" VBox.vgrow="NEVER">
|
||||
<Button text="Add Category" onAction="#addCategory"/>
|
||||
<Button text="Add Default Categories" onAction="#addDefaultCategories"/>
|
||||
</HBox>
|
||||
<ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="categoriesVBox" styleClass="tile-container"/>
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
|
@ -55,7 +55,7 @@
|
|||
</PropertiesPane>
|
||||
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?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.EditCategoryController"
|
||||
>
|
||||
<top>
|
||||
<Label text="Edit Transaction Category" styleClass="bold-text,large-font,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Name" labelFor="${nameField}"/>
|
||||
<TextField fx:id="nameField"/>
|
||||
|
||||
<Label text="Color" labelFor="${colorPicker}"/>
|
||||
<ColorPicker fx:id="colorPicker"/>
|
||||
</PropertiesPane>
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding, std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
|
@ -5,6 +5,8 @@
|
|||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
||||
|
@ -27,10 +29,10 @@
|
|||
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="amountField" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
|
||||
<ChoiceBox fx:id="currencyChoiceBox"/>
|
||||
<HBox styleClass="std-spacing">
|
||||
<TextField fx:id="amountField" styleClass="mono-font" HBox.hgrow="ALWAYS"/>
|
||||
<ChoiceBox fx:id="currencyChoiceBox"/>
|
||||
</HBox>
|
||||
|
||||
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||
<TextArea
|
||||
|
@ -43,15 +45,78 @@
|
|||
|
||||
<!-- 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>
|
||||
|
||||
<VBox>
|
||||
<Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
|
||||
<Hyperlink fx:id="vendorsHyperlink" text="Manage vendors" styleClass="small-font"/>
|
||||
</VBox>
|
||||
<ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
|
||||
|
||||
<VBox>
|
||||
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
|
||||
<Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
|
||||
</VBox>
|
||||
<CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
|
||||
|
||||
<VBox>
|
||||
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
|
||||
<Hyperlink fx:id="tagsHyperlink" text="Manage tags" styleClass="small-font"/>
|
||||
</VBox>
|
||||
<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>
|
||||
|
||||
<Label text="Line Items" styleClass="bold-text"/>
|
||||
<VBox maxWidth="Infinity">
|
||||
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
|
||||
<StyledText styleClass="small-font">
|
||||
Line items aren't yet supported. I'm working on it!
|
||||
</StyledText>
|
||||
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
|
||||
<HBox styleClass="std-spacing">
|
||||
<VBox>
|
||||
<Label text="Quantity" styleClass="bold-text,small-font"/>
|
||||
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
|
||||
</VBox>
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<Label text="Value per Item" styleClass="bold-text,small-font"/>
|
||||
<TextField fx:id="lineItemValueField"/>
|
||||
</VBox>
|
||||
</HBox>
|
||||
<VBox>
|
||||
<Label text="Description" styleClass="bold-text,small-font"/>
|
||||
<TextField fx:id="lineItemDescriptionField"/>
|
||||
</VBox>
|
||||
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Add" fx:id="addLineItemAddButton"/>
|
||||
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
|
||||
<VBox fx:id="lineItemsVBox"/>
|
||||
</VBox>
|
||||
</PropertiesPane>
|
||||
|
||||
<!-- Container for attachments -->
|
||||
<VBox styleClass="std-padding">
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?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.EditVendorController"
|
||||
>
|
||||
<top>
|
||||
<Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Name" labelFor="${nameField}"/>
|
||||
<TextField fx:id="nameField"/>
|
||||
|
||||
<Label text="Description" labelFor="${descriptionField}"/>
|
||||
<TextArea fx:id="descriptionField" wrapText="true"/>
|
||||
</PropertiesPane>
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
|
@ -28,7 +28,7 @@
|
|||
<!-- App footer -->
|
||||
<bottom>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Label text="Perfin Version 1.4.0"/>
|
||||
<Label text="Perfin Version 1.5.0"/>
|
||||
<AnchorPane>
|
||||
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
||||
</AnchorPane>
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
[
|
||||
{
|
||||
"name": "Food & Drink",
|
||||
"color": "#10C600",
|
||||
"children": [
|
||||
{"name": "Groceries"},
|
||||
{"name": "Restaurants"},
|
||||
{"name": "Alcohol & Bars"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Transportation",
|
||||
"color": "#4688FF",
|
||||
"children": [
|
||||
{"name": "Car & Fuel"},
|
||||
{"name": "Public Transport"},
|
||||
{"name": "Air Travel"},
|
||||
{"name": "Taxi & Rideshare"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Household",
|
||||
"color": "#E5DF00",
|
||||
"children": [
|
||||
{"name": "Rent & Mortgage"},
|
||||
{"name": "Utilities"},
|
||||
{"name": "Insurance & Fees"},
|
||||
{"name": "Home Improvements & Renovation"},
|
||||
{"name": "Household Supplies"},
|
||||
{"name": "Pets"},
|
||||
{"name": "Garden"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Shopping",
|
||||
"color": "#BF2484",
|
||||
"children": [
|
||||
{"name": "Clothes & Accessories"},
|
||||
{"name": "Electronics"},
|
||||
{"name": "Hobbies & Crafts"},
|
||||
{"name": "Gifts"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Leisure",
|
||||
"color": "#C7271C",
|
||||
"children": [
|
||||
{"name": "Culture & Events"},
|
||||
{"name": "Movies & Media"},
|
||||
{"name": "Vacation"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Health & Wellness",
|
||||
"color": "#BE11D7",
|
||||
"children": [
|
||||
{"name": "Healthcare"},
|
||||
{"name": "Medication"},
|
||||
{"name": "Vision"},
|
||||
{"name": "Dental"},
|
||||
{"name": "Fitness & Sports"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Income",
|
||||
"color": "#83F25C",
|
||||
"children": [
|
||||
{"name": "Cash Deposits"},
|
||||
{"name": "Salary"},
|
||||
{"name": "Pension"},
|
||||
{"name": "Investments"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Transfers",
|
||||
"color": "#F7AE39"
|
||||
},
|
||||
{
|
||||
"name": "Charity",
|
||||
"color": "#9AE5FF"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Migration to add additional properties to transactions as per this GitHub issue:
|
||||
https://github.com/andrewlalis/perfin/issues/10
|
||||
|
||||
Adds the following:
|
||||
- An optional "vendor" field and associated vendor entity.
|
||||
- An optional "category" field and associated category entity.
|
||||
- An optional set of "tags" that are user-defined strings.
|
||||
- An optional set of "line items" that comprise some subtotal of the transaction
|
||||
and can be used to specify that X amount of the total was spent on some
|
||||
specific item.
|
||||
- An optional address of the purchase.
|
||||
*/
|
||||
|
||||
CREATE TABLE transaction_vendor (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_category (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
parent_id BIGINT DEFAULT NULL,
|
||||
name VARCHAR(63) NOT NULL UNIQUE,
|
||||
color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
|
||||
CONSTRAINT fk_transaction_category_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_tag (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(63) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_tag_join (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
tag_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
CONSTRAINT fk_transaction_tag_join_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_tag_join_tag
|
||||
FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_line_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
transaction_id BIGINT NOT NULL,
|
||||
value_per_item NUMERIC(12, 4) NOT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_transaction_line_item_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT ck_transaction_line_item_quantity_positive
|
||||
CHECK quantity > 0
|
||||
);
|
||||
|
||||
ALTER TABLE transaction
|
||||
ADD COLUMN vendor_id BIGINT DEFAULT NULL AFTER description;
|
||||
ALTER TABLE transaction
|
||||
ADD COLUMN category_id BIGINT DEFAULT NULL AFTER vendor_id;
|
||||
ALTER TABLE transaction
|
||||
ADD CONSTRAINT fk_transaction_vendor
|
||||
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL;
|
||||
ALTER TABLE transaction
|
||||
ADD CONSTRAINT fk_transaction_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
Migration to clean up history entities so that they are easier to work with, and
|
||||
less prone to errors.
|
||||
|
||||
- Removes existing account history items.
|
||||
- Adds a generic history table and history items that are linked to a history.
|
||||
- Adds history links to accounts and transactions.
|
||||
*/
|
||||
|
||||
CREATE TABLE history (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT
|
||||
);
|
||||
|
||||
CREATE TABLE history_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
history_id BIGINT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
type VARCHAR(63) NOT NULL,
|
||||
CONSTRAINT fk_history_item_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE history_item_text (
|
||||
id BIGINT NOT NULL,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_history_item_text_pk
|
||||
FOREIGN KEY (id) REFERENCES history_item(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE history_account (
|
||||
account_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (account_id, history_id),
|
||||
CONSTRAINT fk_history_account_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_history_account_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE history_transaction (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, history_id),
|
||||
CONSTRAINT fk_history_transaction_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_history_transaction_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS account_history_item_text;
|
||||
DROP TABLE IF EXISTS account_history_item_account_entry;
|
||||
DROP TABLE IF EXISTS account_history_item_balance_record;
|
||||
DROP TABLE IF EXISTS account_history_item;
|
||||
|
||||
|
|
@ -8,14 +8,6 @@ CREATE TABLE account (
|
|||
currency VARCHAR(3) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE transaction (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
amount NUMERIC(12, 4) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
description VARCHAR(255) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE attachment (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
uploaded_at TIMESTAMP NOT NULL,
|
||||
|
@ -24,6 +16,45 @@ CREATE TABLE attachment (
|
|||
content_type VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
/* TRANSACTION ENTITIES */
|
||||
|
||||
CREATE TABLE transaction_vendor (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_category (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
parent_id BIGINT DEFAULT NULL,
|
||||
name VARCHAR(63) NOT NULL UNIQUE,
|
||||
color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
|
||||
CONSTRAINT fk_transaction_category_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_tag (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(63) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
amount NUMERIC(12, 4) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
vendor_id BIGINT DEFAULT NULL,
|
||||
category_id BIGINT DEFAULT NULL,
|
||||
CONSTRAINT fk_transaction_vendor
|
||||
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_category
|
||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE account_entry (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
|
@ -52,6 +83,34 @@ CREATE TABLE transaction_attachment (
|
|||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_tag_join (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
tag_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
CONSTRAINT fk_transaction_tag_join_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_transaction_tag_join_tag
|
||||
FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_line_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
transaction_id BIGINT NOT NULL,
|
||||
value_per_item NUMERIC(12, 4) NOT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
idx INT NOT NULL DEFAULT 0,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_transaction_line_item_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT ck_transaction_line_item_quantity_positive
|
||||
CHECK quantity > 0
|
||||
);
|
||||
|
||||
/* BALANCE RECORD ENTITIES */
|
||||
|
||||
CREATE TABLE balance_record (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
|
@ -75,42 +134,49 @@ CREATE TABLE balance_record_attachment (
|
|||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item (
|
||||
/* HISTORY */
|
||||
CREATE TABLE history (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT
|
||||
);
|
||||
|
||||
CREATE TABLE history_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
history_id BIGINT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
account_id BIGINT NOT NULL,
|
||||
type VARCHAR(63) NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
CONSTRAINT fk_history_item_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_text (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
CREATE TABLE history_item_text (
|
||||
id BIGINT PRIMARY KEY,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_text_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
CONSTRAINT fk_history_item_text_pk
|
||||
FOREIGN KEY (id) REFERENCES history_item(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_account_entry (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
entry_id BIGINT NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_account_entry_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
CREATE TABLE history_account (
|
||||
account_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (account_id, history_id),
|
||||
CONSTRAINT fk_history_account_account
|
||||
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_history_item_account_entry
|
||||
FOREIGN KEY (entry_id) REFERENCES account_entry(id)
|
||||
CONSTRAINT fk_history_account_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE account_history_item_balance_record (
|
||||
item_id BIGINT NOT NULL PRIMARY KEY,
|
||||
record_id BIGINT NOT NULL,
|
||||
CONSTRAINT fk_account_history_item_balance_record_pk
|
||||
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
|
||||
CREATE TABLE history_transaction (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, history_id),
|
||||
CONSTRAINT fk_history_transaction_transaction
|
||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_account_history_item_balance_record
|
||||
FOREIGN KEY (record_id) REFERENCES balance_record(id)
|
||||
CONSTRAINT fk_history_transaction_history
|
||||
FOREIGN KEY (history_id) REFERENCES history(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.BorderPane?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TagsViewController"
|
||||
>
|
||||
<top>
|
||||
<Label text="Transaction Tags" styleClass="large-font,bold-text,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<StyledText maxWidth="500" styleClass="std-padding">
|
||||
Transaction tags are just bits of text that can be applied to a
|
||||
transaction to give it additional meaning or make searching for
|
||||
certain transactions easier.
|
||||
--
|
||||
Tags are automatically created if you add a new one to a
|
||||
transaction, and they'll show up here. When you remove a tag,
|
||||
it will be permanently removed from **all** transactions that it
|
||||
was previously associated with.
|
||||
</StyledText>
|
||||
<ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="tagsVBox" styleClass="tile-container"/>
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
|
@ -6,6 +6,7 @@
|
|||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import javafx.scene.text.TextFlow?>
|
||||
<?import javafx.scene.shape.Circle?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
|
||||
|
@ -32,6 +33,37 @@
|
|||
<Label text="Description" styleClass="bold-text"/>
|
||||
<Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<PropertiesPane vgap="5" hgap="5">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
||||
</columnConstraints>
|
||||
<Label text="Vendor" styleClass="bold-text"/>
|
||||
<Label fx:id="vendorLabel"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<PropertiesPane vgap="5" hgap="5">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
||||
</columnConstraints>
|
||||
<Label text="Category" styleClass="bold-text"/>
|
||||
<HBox styleClass="std-spacing">
|
||||
<Circle radius="8" fx:id="categoryColorIndicator"/>
|
||||
<Label fx:id="categoryLabel"/>
|
||||
</HBox>
|
||||
</PropertiesPane>
|
||||
|
||||
<PropertiesPane vgap="5" hgap="5">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
||||
</columnConstraints>
|
||||
<Label text="Tags" styleClass="bold-text"/>
|
||||
<Label fx:id="tagsLabel"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<VBox>
|
||||
<TextFlow>
|
||||
<Text text="Debited to"/>
|
||||
|
@ -45,7 +77,7 @@
|
|||
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
|
||||
<Button text="Edit" onAction="#editTransaction"/>
|
||||
<Button text="Delete this transaction" onAction="#deleteTransaction"/>
|
||||
<Button text="Delete" onAction="#deleteTransaction"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
||||
|
@ -20,7 +21,8 @@
|
|||
<HBox>
|
||||
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
|
||||
<top>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<HBox styleClass="padding-extra,std-spacing">
|
||||
<TextField fx:id="searchField" promptText="Search"/>
|
||||
<PropertiesPane hgap="5" vgap="5">
|
||||
<Label text="Filter by Account"/>
|
||||
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.VendorsViewController"
|
||||
>
|
||||
<top>
|
||||
<Label text="Vendors" styleClass="large-font,bold-text,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<StyledText maxWidth="500" styleClass="std-padding">
|
||||
Vendors are businesses or other financial entities with which
|
||||
you do transactions. By tagging a vendor on your transactions,
|
||||
it becomes easier to find out just how much money you're
|
||||
spending at certain shops, and how often. It can also make it a
|
||||
lot easier to look up past transactions.
|
||||
</StyledText>
|
||||
<HBox styleClass="std-padding,std-spacing" VBox.vgrow="NEVER">
|
||||
<Button text="Add Vendor" onAction="#addVendor"/>
|
||||
</HBox>
|
||||
<ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="vendorsVBox" styleClass="tile-container"/>
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
Loading…
Reference in New Issue