diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index de3384b..ed0d8da 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -93,6 +93,8 @@ public class PerfinApp extends Application { 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")); // Help pages. helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml")); diff --git a/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java new file mode 100644 index 0000000..abc613f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java @@ -0,0 +1,33 @@ +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.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; + +public class CategoriesViewController implements RouteSelectionListener { + @FXML public VBox categoriesVBox; + private final ObservableList 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(); + } + + private void refreshCategories() { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionCategoryRepository.class, + TransactionCategoryRepository::findTree + ).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes))); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java new file mode 100644 index 0000000..792990e --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java @@ -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() + .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(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index a8e2d83..ab3052f 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -62,7 +62,9 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public ComboBox vendorComboBox; @FXML public Hyperlink vendorsHyperlink; @FXML public ComboBox categoryComboBox; + @FXML public Hyperlink categoriesHyperlink; @FXML public ComboBox tagsComboBox; + @FXML public Hyperlink tagsHyperlink; @FXML public Button addTagButton; @FXML public VBox tagsVBox; private final ObservableList selectedTags = FXCollections.observableArrayList(); @@ -92,6 +94,8 @@ public class EditTransactionController implements RouteSelectionListener { initializeTagSelectionUi(); // Setup hyperlinks. vendorsHyperlink.setOnAction(event -> router.navigate("vendors")); + categoriesHyperlink.setOnAction(event -> router.navigate("categories")); +// tagsHyperlink.setOnAction(event -> router.navigate("tags")); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); diff --git a/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java index acf4916..fb7598d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java @@ -5,15 +5,11 @@ 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.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import java.util.List; @@ -25,36 +21,7 @@ public class VendorsViewController implements RouteSelectionListener { private final ObservableList vendors = FXCollections.observableArrayList(); @FXML public void initialize() { - BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, this::buildVendorTile); - } - - private Node buildVendorTile(TransactionVendor transactionVendor) { - BorderPane pane = new BorderPane(); - pane.getStyleClass().addAll("tile", "std-spacing"); - pane.setOnMouseClicked(event -> router.navigate("edit-vendor", transactionVendor)); - - Label nameLabel = new Label(transactionVendor.getName()); - nameLabel.getStyleClass().addAll("bold-text"); - Label descriptionLabel = new Label(transactionVendor.getDescription()); - descriptionLabel.setWrapText(true); - VBox contentVBox = new VBox(nameLabel, descriptionLabel); - contentVBox.getStyleClass().addAll("std-spacing"); - pane.setCenter(contentVBox); - BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT); - - Button removeButton = new Button("Remove"); - removeButton.setOnAction(event -> { - boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions with 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); - }); - refreshVendors(); - } - }); - pane.setRight(removeButton); - - return pane; + BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors)); } @Override diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java index 71a3f3b..a996349 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -13,5 +13,9 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable List 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 children){} + List findTree(); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java index 3eb3901..7dcd072 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -9,6 +9,7 @@ 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; @@ -69,11 +70,62 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran ); } + @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 findTree() { + List rootCategories = DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + List 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 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(); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java new file mode 100644 index 0000000..979f736 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java @@ -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", "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)); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java new file mode 100644 index 0000000..9def0ce --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java @@ -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; + } +} diff --git a/src/main/resources/categories-view.fxml b/src/main/resources/categories-view.fxml new file mode 100644 index 0000000..b92e9a7 --- /dev/null +++ b/src/main/resources/categories-view.fxml @@ -0,0 +1,26 @@ + + + + + + + + + + + +
+ + +
+
diff --git a/src/main/resources/edit-category.fxml b/src/main/resources/edit-category.fxml new file mode 100644 index 0000000..3c47818 --- /dev/null +++ b/src/main/resources/edit-category.fxml @@ -0,0 +1,33 @@ + + + + + + + + +
+ + + + + + + + + +
+
diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index 874977f..1d0900b 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -68,13 +68,13 @@ diff --git a/src/main/resources/vendors-view.fxml b/src/main/resources/vendors-view.fxml index 343940f..b409248 100644 --- a/src/main/resources/vendors-view.fxml +++ b/src/main/resources/vendors-view.fxml @@ -3,10 +3,7 @@ - - - - +