diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java index 24e6de5..5ee517a 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java @@ -4,6 +4,7 @@ import dev.mvvasilev.common.controller.AbstractRestController; import dev.mvvasilev.common.web.APIResponseDTO; import dev.mvvasilev.common.web.CrudResponseDTO; import dev.mvvasilev.finances.dtos.*; +import dev.mvvasilev.finances.enums.CategorizationRule; import dev.mvvasilev.finances.services.CategoryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -11,6 +12,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.Arrays; import java.util.Collection; @RestController @@ -24,6 +26,16 @@ public class CategoriesController extends AbstractRestController { this.categoryService = categoryService; } + @GetMapping("/rules") + public ResponseEntity>> fetchCategorizationRules() { + return ok( + Arrays.stream(CategorizationRule.values()).map(r -> new CategorizationRuleDTO( + r, + r.applicableForType() + )).toList() + ); + } + @PostMapping public ResponseEntity> createCategory( @RequestBody CreateCategoryDTO dto, @@ -60,11 +72,12 @@ public class CategoriesController extends AbstractRestController { @PostMapping("/{categoryId}/rules") @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))") - public ResponseEntity>> createCategorizationRule( + public ResponseEntity>> createCategorizationRules( @PathVariable("categoryId") Long categoryId, - @RequestBody Collection dto + @RequestBody Collection dto, + Authentication authentication ) { - return created(categoryService.createCategorizationRules(categoryId, dto)); + return created(categoryService.createCategorizationRules(categoryId, Integer.parseInt(authentication.getName()), dto)); } // @DeleteMapping("/{categoryId}/rules/{ruleId}") diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java index feacc82..2b12418 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java @@ -7,7 +7,9 @@ import java.time.LocalDateTime; public record CategorizationDTO( Long id, - CategorizationRule rule, + CategorizationRuleDTO rule, + + ProcessedTransactionFieldDTO ruleBasedOn, String stringValue, diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationRuleDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationRuleDTO.java new file mode 100644 index 0000000..e744909 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationRuleDTO.java @@ -0,0 +1,9 @@ +package dev.mvvasilev.finances.dtos; + +import dev.mvvasilev.finances.enums.CategorizationRule; +import dev.mvvasilev.finances.enums.RawTransactionValueType; + +public record CategorizationRuleDTO( + CategorizationRule rule, + RawTransactionValueType applicableType +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategoryDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategoryDTO.java index 78b43d6..56ddd78 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategoryDTO.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategoryDTO.java @@ -1,7 +1,10 @@ package dev.mvvasilev.finances.dtos; +import dev.mvvasilev.finances.enums.CategorizationRuleBehavior; + public record CategoryDTO( Long id, - String name + String name, + CategorizationRuleBehavior ruleBehavior ) { } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateCategorizationDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateCategorizationDTO.java index 205bc57..75789b6 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateCategorizationDTO.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateCategorizationDTO.java @@ -1,31 +1,36 @@ package dev.mvvasilev.finances.dtos; import dev.mvvasilev.finances.enums.CategorizationRule; +import dev.mvvasilev.finances.enums.ProcessedTransactionField; +import jakarta.annotation.Nullable; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Null; import org.hibernate.validator.constraints.Length; import java.time.LocalDateTime; +import java.util.Optional; public record CreateCategorizationDTO ( @NotNull CategorizationRule rule, + @NotNull + ProcessedTransactionField ruleBasedOn, + @Length(max = 1024) - String stringValue, + Optional stringValue, - Double numericGreaterThan, + Optional numericGreaterThan, - Double numericLessThan, + Optional numericLessThan, - Double numericValue, + Optional numericValue, - LocalDateTime timestampGreaterThan, + Optional timestampGreaterThan, - LocalDateTime timestampLessThan, + Optional timestampLessThan, - Boolean booleanValue, + Optional booleanValue, @Valid CreateCategorizationDTO left, diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/UpdateCategoryDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/UpdateCategoryDTO.java index 511f470..2ee6be5 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/UpdateCategoryDTO.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/UpdateCategoryDTO.java @@ -1,11 +1,14 @@ package dev.mvvasilev.finances.dtos; +import dev.mvvasilev.finances.enums.CategorizationRuleBehavior; import jakarta.validation.constraints.NotNull; import org.hibernate.validator.constraints.Length; public record UpdateCategoryDTO ( @NotNull @Length(max = 255) - String name + String name, + @NotNull + CategorizationRuleBehavior ruleBehavior ) { } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Categorization.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Categorization.java index b42f58e..0fa2e8c 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Categorization.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Categorization.java @@ -24,17 +24,17 @@ public class Categorization extends AbstractEntity implements UserOwned { private String stringValue; - private double numericGreaterThan; + private Double numericGreaterThan; - private double numericLessThan; + private Double numericLessThan; - private double numericValue; + private Double numericValue; private LocalDateTime timestampGreaterThan; private LocalDateTime timestampLessThan; - private boolean booleanValue; + private Boolean BooleanValue; private Long categoryId; @@ -78,27 +78,27 @@ public class Categorization extends AbstractEntity implements UserOwned { this.stringValue = stringValue; } - public double getNumericGreaterThan() { + public Double getNumericGreaterThan() { return numericGreaterThan; } - public void setNumericGreaterThan(double numericGreaterThan) { + public void setNumericGreaterThan(Double numericGreaterThan) { this.numericGreaterThan = numericGreaterThan; } - public double getNumericLessThan() { + public Double getNumericLessThan() { return numericLessThan; } - public void setNumericLessThan(double numericLessThan) { + public void setNumericLessThan(Double numericLessThan) { this.numericLessThan = numericLessThan; } - public double getNumericValue() { + public Double getNumericValue() { return numericValue; } - public void setNumericValue(double numericValue) { + public void setNumericValue(Double numericValue) { this.numericValue = numericValue; } @@ -118,12 +118,12 @@ public class Categorization extends AbstractEntity implements UserOwned { this.timestampLessThan = timestampLessThan; } - public boolean getBooleanValue() { - return booleanValue; + public Boolean getBooleanValue() { + return BooleanValue; } - public void setBooleanValue(boolean booleanValue) { - this.booleanValue = booleanValue; + public void setBooleanValue(Boolean BooleanValue) { + this.BooleanValue = BooleanValue; } public Long getCategoryId() { diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/TransactionCategory.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/TransactionCategory.java index e586c0b..d97c297 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/TransactionCategory.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/TransactionCategory.java @@ -2,6 +2,8 @@ package dev.mvvasilev.finances.entity; import dev.mvvasilev.common.data.AbstractEntity; import dev.mvvasilev.common.data.UserOwned; +import dev.mvvasilev.finances.enums.CategorizationRuleBehavior; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -13,6 +15,9 @@ public class TransactionCategory extends AbstractEntity implements UserOwned { private String name; + @Convert(converter = CategorizationRuleBehavior.JpaConverter.class) + private CategorizationRuleBehavior ruleBehavior; + public TransactionCategory() { } @@ -31,4 +36,12 @@ public class TransactionCategory extends AbstractEntity implements UserOwned { public void setName(String name) { this.name = name; } + + public CategorizationRuleBehavior getRuleBehavior() { + return ruleBehavior; + } + + public void setRuleBehavior(CategorizationRuleBehavior ruleBehavior) { + this.ruleBehavior = ruleBehavior; + } } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/CategorizationRuleBehavior.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/CategorizationRuleBehavior.java new file mode 100644 index 0000000..f9b91df --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/enums/CategorizationRuleBehavior.java @@ -0,0 +1,21 @@ +package dev.mvvasilev.finances.enums; + +import dev.mvvasilev.common.data.AbstractEnumConverter; +import dev.mvvasilev.common.data.PersistableEnum; + +public enum CategorizationRuleBehavior implements PersistableEnum { + ANY, + ALL, + NONE; + + @Override + public String value() { + return name(); + } + + public static class JpaConverter extends AbstractEnumConverter { + public JpaConverter() { + super(CategorizationRuleBehavior.class); + } + } +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/CategorizationRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/CategorizationRepository.java index 6327f78..0adcc0b 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/CategorizationRepository.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/CategorizationRepository.java @@ -25,27 +25,21 @@ public interface CategorizationRepository extends JpaRepository fetchForUser(@Param("userId") int userId); - // TODO: Use Recursive CTE @Query( value = """ - WITH RECURSIVE cats AS ( - SELECT cat.* - FROM categories.categorization AS cat - WHERE cat.category_id = :categoryId - - UNION ALL - - SELECT l.* - FROM categories.categorization AS l - JOIN cats ON cats.`left` = l.id - - UNION ALL - - SELECT r.* - FROM categories.categorization AS r - JOIN cats ON cats.`right` = r.id - ) - SELECT * FROM cats; + WITH RECURSIVE + childCats AS ( + SELECT root.* + FROM categories.categorization AS root + WHERE root.category_id = :categoryId + + UNION ALL + + SELECT c.* + FROM categories.categorization AS c, childCats + WHERE childCats.right_categorization_id = c.id OR childCats.left_categorization_id = c.id + ) + SELECT DISTINCT * FROM childCats; """, nativeQuery = true ) diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java index 970404c..116665c 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java @@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence; import dev.mvvasilev.finances.dtos.CategoryDTO; import dev.mvvasilev.finances.entity.TransactionCategory; +import dev.mvvasilev.finances.enums.CategorizationRuleBehavior; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,7 +16,16 @@ public interface TransactionCategoryRepository extends JpaRepository fetchTransactionCategoriesWithUserId(@Param("userId") int userId); - @Query(value = "UPDATE categories.transaction_category SET name = :name WHERE id = :categoryId", nativeQuery = true) + @Query(value = """ + UPDATE TransactionCategory tc + SET tc.name = :name, tc.ruleBehavior = :ruleBehavior + WHERE tc.id = :categoryId + """ + ) @Modifying - int updateTransactionCategoryName(@Param("categoryId") Long categoryId, @Param("name") String name); + int updateTransactionCategoryName( + @Param("categoryId") Long categoryId, + @Param("name") String name, + @Param("ruleBehavior") CategorizationRuleBehavior ruleBehavior + ); } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java index 9052f98..b8fb1f8 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java @@ -4,10 +4,7 @@ import dev.mvvasilev.common.data.AbstractEntity; import dev.mvvasilev.common.exceptions.CommonFinancesException; import dev.mvvasilev.common.web.CrudResponseDTO; import dev.mvvasilev.finances.dtos.*; -import dev.mvvasilev.finances.entity.Categorization; -import dev.mvvasilev.finances.entity.ProcessedTransaction; -import dev.mvvasilev.finances.entity.ProcessedTransactionCategory; -import dev.mvvasilev.finances.entity.TransactionCategory; +import dev.mvvasilev.finances.entity.*; import dev.mvvasilev.finances.persistence.CategorizationRepository; import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository; import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository; @@ -18,10 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -64,14 +58,15 @@ public class CategoryService { public Collection listForUser(int userId) { return transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId) .stream() - .map(entity -> new CategoryDTO(entity.getId(), entity.getName())) + .map(entity -> new CategoryDTO(entity.getId(), entity.getName(), entity.getRuleBehavior())) .collect(Collectors.toList()); } public int update(Long categoryId, UpdateCategoryDTO dto) { return transactionCategoryRepository.updateTransactionCategoryName( categoryId, - dto.name() + dto.name(), + dto.ruleBehavior() ); } @@ -81,28 +76,53 @@ public class CategoryService { } public void categorizeForUser(int userId) { + final var categories = transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId); final var categorizations = categorizationRepository.fetchForUser(userId); final var transactions = processedTransactionRepository.fetchForUser(userId); - // Run all the categorization rules async + // Run each category's rules for all transactions in parallel to eachother final var futures = categorizations.stream() - .map(c -> CompletableFuture.supplyAsync(() -> - transactions.stream() - .map((transaction) -> categorizeTransaction(categorizations, c, transaction)) - .filter(Optional::isPresent) - .map(Optional::get) - .toList()) - ) + .collect(Collectors.groupingBy(Categorization::getCategoryId, HashMap::new, Collectors.toList())) + .entrySet() + .stream() + .map(entry -> CompletableFuture.supplyAsync(() -> { + final var categoryId = entry.getKey(); + final var rules = entry.getValue(); + + final var category = categories.stream().filter(c -> c.getId() == categoryId).findFirst(); + + if (category.isEmpty()) { + throw new CommonFinancesException("Orphaned categorization, invalid categoryId"); + } + + return transactions.stream() + .map(transaction -> { + final var matches = switch (category.get().getRuleBehavior()) { + case ANY -> rules.stream().anyMatch(r -> matchesRule(categorizations, r, transaction)); + case ALL -> rules.stream().allMatch(r -> matchesRule(categorizations, r, transaction)); + case NONE -> rules.stream().noneMatch(r -> matchesRule(categorizations, r, transaction)); + }; + + if (matches) { + return Optional.of(new ProcessedTransactionCategory(transaction.getId(), categoryId)); + } else { + return Optional.empty(); + } + }) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + })) .toArray(length -> (CompletableFuture>[]) new CompletableFuture[length]); // Run them all in parallel - final var categories = CompletableFuture.allOf(futures).thenApply((v) -> + final var ptcs = CompletableFuture.allOf(futures).thenApply((v) -> Arrays.stream(futures) .flatMap(future -> future.join().stream()) .toList() ).join(); - processedTransactionCategoryRepository.saveAllAndFlush(categories); + processedTransactionCategoryRepository.saveAllAndFlush(ptcs); } private Optional categorizeTransaction(final Collection allCategorizations, Categorization categorization, ProcessedTransaction processedTransaction) { @@ -247,45 +267,79 @@ public class CategoryService { } public Collection fetchCategorizationRules(Long categoryId) { - return Lists.newArrayList(); + final var categorizations = categorizationRepository.fetchForCategory(categoryId); + return categorizationRepository.fetchForCategory(categoryId).stream() + .filter(c -> c.getCategoryId() != null) + .map(c -> mapCategorization(categorizations, c)) + .toList(); } - public Collection createCategorizationRules(Long categoryId, Collection dtos) { + private CategorizationDTO mapCategorization(final Collection all, Categorization categorization) { + return new CategorizationDTO( + categorization.getId(), + new CategorizationRuleDTO( + categorization.getCategorizationRule(), + categorization.getCategorizationRule().applicableForType() + ), + categorization.getRuleBasedOn() != null ? + new ProcessedTransactionFieldDTO( + categorization.getRuleBasedOn(), + categorization.getRuleBasedOn().type() + ) : null, + categorization.getStringValue(), + categorization.getNumericGreaterThan(), + categorization.getNumericLessThan(), + categorization.getNumericValue(), + categorization.getTimestampGreaterThan(), + categorization.getTimestampLessThan(), + categorization.getBooleanValue(), + all.stream() + .filter(lc -> categorization.getLeftCategorizationId() != null && lc.getId() == categorization.getLeftCategorizationId()) + .findFirst() + .map(c -> mapCategorization(all, c)) + .orElse(null), + all.stream() + .filter(lc -> categorization.getRightCategorizationId() != null && lc.getId() == categorization.getRightCategorizationId()) + .findFirst() + .map(c -> mapCategorization(all, c)) + .orElse(null) + ); + } + + public Collection createCategorizationRules(Long categoryId, Integer userId, Collection dtos) { categorizationRepository.deleteAllForCategory(categoryId); - final var newCategorizations = dtos.stream() - .map(dto -> saveCategorizationRule(categoryId, dto)) - .toList(); - - return categorizationRepository.saveAllAndFlush(newCategorizations).stream() - .map(AbstractEntity::getId) + return dtos.stream() + .map(dto -> saveCategorizationRule(categoryId, userId, dto).getId()) .toList(); } - private Categorization saveCategorizationRule(Long categoryId, CreateCategorizationDTO dto) { + private Categorization saveCategorizationRule(Long categoryId, Integer userId, CreateCategorizationDTO dto) { // TODO: Avoid recursion final var categorization = new Categorization(); categorization.setCategorizationRule(dto.rule()); - categorization.setCategoryId(null); - categorization.setStringValue(dto.stringValue()); - categorization.setNumericGreaterThan(dto.numericGreaterThan()); - categorization.setNumericLessThan(dto.numericLessThan()); - categorization.setNumericValue(dto.numericValue()); - categorization.setTimestampGreaterThan(dto.timestampGreaterThan()); - categorization.setTimestampLessThan(dto.timestampLessThan()); - categorization.setBooleanValue(dto.booleanValue()); + categorization.setUserId(userId); + categorization.setRuleBasedOn(dto.ruleBasedOn()); + categorization.setCategoryId(categoryId); + categorization.setStringValue(dto.stringValue().orElse(null)); + categorization.setNumericGreaterThan(dto.numericGreaterThan().orElse(null)); + categorization.setNumericLessThan(dto.numericLessThan().orElse(null)); + categorization.setNumericValue(dto.numericValue().orElse(null)); + categorization.setTimestampGreaterThan(dto.timestampGreaterThan().orElse(null)); + categorization.setTimestampLessThan(dto.timestampLessThan().orElse(null)); + categorization.setBooleanValue(dto.booleanValue().orElse(null)); // Only root rules have category id set, to differentiate them from non-roots // TODO: This smells bad. Add an isRoot property instead? if (dto.left() != null) { - final var leftCat = saveCategorizationRule(null, dto.left()); + final var leftCat = saveCategorizationRule(null, userId, dto.left()); categorization.setLeftCategorizationId(leftCat.getId()); } if (dto.right() != null) { - final var rightCat = saveCategorizationRule(null, dto.right()); + final var rightCat = saveCategorizationRule(null, userId, dto.right()); categorization.setRightCategorizationId(rightCat.getId()); } diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.15__AddCategoryRuleBehavior.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.15__AddCategoryRuleBehavior.sql new file mode 100644 index 0000000..4c9a40d --- /dev/null +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.15__AddCategoryRuleBehavior.sql @@ -0,0 +1 @@ +ALTER TABLE categories.transaction_category ADD COLUMN IF NOT EXISTS rule_behavior VARCHAR(255); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b0d08a..8f6a7f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,12 +14,15 @@ "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/x-data-grid": "^6.18.6", + "@mui/x-date-pickers": "^6.18.6", + "dayjs": "^1.11.10", "dotenv": "^16.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.21.0", + "uuid": "^9.0.1", "vis-data": "^7.1.9", "vis-network": "^9.1.9" }, @@ -1372,6 +1375,71 @@ "react-dom": "^17.0.0 || ^18.0.0" } }, + "node_modules/@mui/x-date-pickers": { + "version": "6.18.6", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.18.6.tgz", + "integrity": "sha512-pqOrGPUDVY/1xXrM1hofqwgquno/SB9aG9CVS1m2Rs8hKF1VWRC+jYlEa1Qk08xKmvkia5g7NsdV/BBb+tHUZw==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/base": "^5.0.0-beta.22", + "@mui/utils": "^5.14.16", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "date-fns": "^2.25.0", + "date-fns-jalali": "^2.13.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2089,6 +2157,11 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4672,7 +4745,6 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "peer": true, "bin": { "uuid": "dist/bin/uuid" } diff --git a/frontend/package.json b/frontend/package.json index 4bf3de4..8753695 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,12 +16,15 @@ "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/x-data-grid": "^6.18.6", + "@mui/x-date-pickers": "^6.18.6", + "dayjs": "^1.11.10", "dotenv": "^16.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.21.0", + "uuid": "^9.0.1", "vis-data": "^7.1.9", "vis-network": "^9.1.9" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 980b9f0..847cb1c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,17 +1,19 @@ import { Routes, Route } from 'react-router-dom'; -import HomePage from "@/app/pages/HomePage" import RootLayout from '@/app/Layout'; -import StatementsPage from './app/pages/StatementsPage.jsx'; +import StatisticsPage from "@/app/pages/StatisticsPage.jsx" +import StatementsPage from '@/app/pages/StatementsPage.jsx'; import TransactionsPage from "@/app/pages/TransactionsPage.jsx"; +import CategoriesPage from "@/app/pages/CategoriesPage.jsx"; function App() { return ( <> - } /> + } /> } /> } /> + } /> diff --git a/frontend/src/app/Layout.jsx b/frontend/src/app/Layout.jsx index 6007061..2eb6bc6 100644 --- a/frontend/src/app/Layout.jsx +++ b/frontend/src/app/Layout.jsx @@ -18,6 +18,11 @@ import {Logout as LogoutIcon} from '@mui/icons-material'; import {Login as LoginIcon} from "@mui/icons-material"; import {Toaster} from 'react-hot-toast'; import theme from '../components/ThemeRegistry/theme'; +import utils from "@/utils.js"; +import {CircularProgress} from "@mui/material"; +import {useEffect, useState} from "react"; +import {LocalizationProvider} from "@mui/x-date-pickers"; +import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; const DRAWER_WIDTH = 240; @@ -49,10 +54,38 @@ function isLoggedIn() { } export default function RootLayout({children}) { + + const [spinner, showSpinner] = useState(false); + + useEffect(() => { + window.addEventListener("onSpinnerStatusChange", () => { + showSpinner(utils.isSpinnerShown()); + }) + }, []); + return ( + { + + + + } + } - - {children} - + + + {children} + + { + utils.showSpinner(); + + toast.promise( + fetchCategories(), + { + loading: "Loading...", + success: () => { + utils.hideSpinner(); + + return "Ready"; + }, + error: (err) => { + utils.hideSpinner(); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ) + }, []); + + function fetchCategories() { + return utils.performRequest("/api/categories") + .then(resp => resp.json()) + .then(({result}) => setCategories(result)); + } + + function createNewCategory() { + utils.showSpinner(); + + toast.promise( + utils.performRequest("/api/categories", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name: newCategoryName + }) + }).then(resp => fetchCategories()), + { + loading: "Saving...", + success: () => { + openCategoryModal(false); + utils.hideSpinner(); + + return "Saved"; + }, + error: (err) => { + openCategoryModal(false); + utils.hideSpinner(); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ); + } + + function deleteSelectedCategory() { + utils.showSpinner(); + + toast.promise( + utils.performRequest(`/api/categories/${selectedCategory.id}`, { + method: "DELETE" + }).then(resp => fetchCategories()), + { + loading: "Deleting...", + success: () => { + openConfirmDeleteCategoryModal(false); + setSelectedCategory(null); + utils.hideSpinner(); + + return "Deleted"; + }, + error: (err) => { + openConfirmDeleteCategoryModal(false); + setSelectedCategory(null); + utils.hideSpinner(); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ); + } + + function applyCategorizationRules() { + utils.showSpinner(); + + toast.promise( + utils.performRequest(`/api/categories/categorize`, { + method: "POST" + }), + { + loading: "Deleting...", + success: () => { + openApplyRulesConfirmModal(false); + utils.hideSpinner(); + + return "Applied"; + }, + error: (err) => { + openApplyRulesConfirmModal(false); + utils.hideSpinner(); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ); + } + + function saveCategory(category) { + utils.performRequest(`/api/categories/${category.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + ...category, + ruleBehavior: category.ruleBehavior ?? "ANY", + }) + }); + } + + return ( + + + + + + + + + + + + + + + { + categories.map(c => { + let variant = (selectedCategory?.id ?? -1) === c.id ? "filled" : "outlined"; + + return ( + { + setSelectedCategory({...c}); + }} + onDelete={() => { + setSelectedCategory(c); + openConfirmDeleteCategoryModal(true); + }} + label={c.name} + deleteIcon={} + variant={variant} + /> + ); + }) + } + + + + + + + + + { + selectedCategory && + { + selectedCategory.ruleBehavior = value; + setSelectedCategory({...selectedCategory}); + }} + onSave={() => saveCategory(selectedCategory)} + /> + } + + + + +

Create New Category

+ + + + setNewCategoryName(e.target.value)} + autoFocus + sx={{width: "100%"}} + /> + + + + + + + + +
+
+ + + {`Delete Category "${selectedCategory?.name}"?`} + + + + Deleting a category will also clear it from all transactions it is currently applied to + + + + + + + + + + {"Apply all categorization rules?"} + + + + Applying all categorization rules to your current transactions will wipe all categories + assigned to them, and re-assign them based on the rules as currently defined. + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/pages/StatementsPage.jsx b/frontend/src/app/pages/StatementsPage.jsx index 3ba5748..81a4ca4 100644 --- a/frontend/src/app/pages/StatementsPage.jsx +++ b/frontend/src/app/pages/StatementsPage.jsx @@ -9,6 +9,7 @@ import {Stack} from "@mui/material"; import StatementCard from "@/components/statements/StatementCard.jsx"; import Carousel from "react-material-ui-carousel"; import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx"; +import Divider from "@mui/material/Divider"; export default function StatementsPage() { @@ -22,9 +23,15 @@ export default function StatementsPage() { }, []); function fetchStatements() { + utils.showSpinner(); + utils.performRequest("/api/statements") .then(resp => resp.json()) - .then(({ result }) => setStatements(result)); + .then(({ result }) => { + setStatements(result); + + utils.hideSpinner(); + }); } async function uploadStatement({ target }) { @@ -33,11 +40,13 @@ export default function StatementsPage() { let formData = new FormData(); formData.append("file", file); + utils.showSpinner(); + await toast.promise( utils.performRequest("/api/statements/uploadSheet", { - method: "POST", - body: formData - }), + method: "POST", + body: formData + }), { loading: "Uploading...", success: () => { @@ -45,7 +54,11 @@ export default function StatementsPage() { return "Upload successful!"; }, - error: (err) => `Uh oh, something went wrong: ${err}` + error: (err) => { + utils.hideSpinner(); + + return `Uh oh, something went wrong: ${err}`; + } } ); } @@ -138,6 +151,10 @@ export default function StatementsPage() { } + + + + { mappingStatementId !== -1 && diff --git a/frontend/src/app/pages/HomePage.jsx b/frontend/src/app/pages/StatisticsPage.jsx similarity index 96% rename from frontend/src/app/pages/HomePage.jsx rename to frontend/src/app/pages/StatisticsPage.jsx index 4b7d4f9..fd4eaa6 100644 --- a/frontend/src/app/pages/HomePage.jsx +++ b/frontend/src/app/pages/StatisticsPage.jsx @@ -3,7 +3,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import MediaCard from '@/components/MediaCard'; import { Stack } from '@mui/material'; -export default function HomePage() { +export default function StatisticsPage() { return (
diff --git a/frontend/src/app/pages/TransactionsPage.jsx b/frontend/src/app/pages/TransactionsPage.jsx index 397b0c3..1cb52f8 100644 --- a/frontend/src/app/pages/TransactionsPage.jsx +++ b/frontend/src/app/pages/TransactionsPage.jsx @@ -3,6 +3,7 @@ import {Stack} from "@mui/material"; import {useEffect, useState} from "react"; import {DataGrid} from "@mui/x-data-grid"; import utils from "@/utils.js"; +import {ArrowDownward, ArrowUpward, PriceChange} from "@mui/icons-material"; const COLUMNS = [ { @@ -12,6 +13,19 @@ const COLUMNS = [ width: 150, sortable: false, filterable: false, + renderCell: (params) => { + return params.value ? ( + <> + + + + ) : ( + <> + + + + ); + } }, { field: "amount", @@ -21,7 +35,7 @@ const COLUMNS = [ flex: true, sortable: true, filterable: false, - valueFormatter: val => `${val.value} лв.` + valueFormatter: val => `${(val.value).toLocaleString(undefined, { minimumFractionDigits: 2 })} лв.` }, { field: "description", @@ -40,7 +54,7 @@ const COLUMNS = [ flex: true, sortable: true, filterable: false, - valueFormatter: val => new Date(val.value).toLocaleString('en-UK') + valueFormatter: val => new Date(val.value).toLocaleString("bg-BG") }, ]; @@ -48,7 +62,7 @@ export default function TransactionsPage() { const [pageOptions, setPageOptions] = useState({ page: 0, - pageSize: 100, + pageSize: 50, }); const [sortOptions, setSortOptions] = useState([ @@ -61,6 +75,8 @@ export default function TransactionsPage() { const [transactions, setTransactions] = useState({}); useEffect(() => { + utils.showSpinner(); + // Multi-sorting requires the MUI data grid pro license :) let sortBy = sortOptions.map((sort) => `&sort=${sort.field},${sort.sort}`).join("") @@ -68,17 +84,34 @@ export default function TransactionsPage() { utils.performRequest(`/api/processed-transactions?page=${pageOptions.page}&size=${pageOptions.pageSize}${sortBy}`) .then(resp => resp.json()) - .then(({result}) => setTransactions(result)); + .then(({result}) => { + setTransactions(result); + utils.hideSpinner(); + }); }, [pageOptions, sortOptions]); return ( - - - + + + f.field === value); + + if (field) { + rule.ruleBasedOn = { + field: value, + type: field.type + }; + + rule.rule = undefined; + rule.left = undefined; + rule.right = undefined; + } else { + switch (value) { + case "AND": + case "OR": + rule.left = { + id: utils.generateUUID() + }; + rule.right = { + id: utils.generateUUID() + }; + break; + case "NOT": + rule.left = undefined; + + rule.right = { + id: utils.generateUUID() + }; + break; + } + + rule.rule = { + rule: value, + applicableType: undefined + }; + + rule.ruleBasedOn = undefined; + } + + updateRule(); + } + + function updateRule() { + setRule({...rule}); + updateRuleData(rule); + } + + function fieldsAndLogicalOperators() { + return fields.map(f => { return { name: f.field, type: f.type } }) + .concat( + ruleTypes.filter(rt => rt.applicableType === null || rt.applicableType === undefined) + .map(rt => { + return { + name: rt.rule, + type: rt.applicableType + } + }) + ); + } + + function ruleTypeName(ruleType) { + switch (ruleType) { + case "STRING_REGEX": return "matches"; + case "STRING_CONTAINS": return "contains"; + case "BOOLEAN_EQ": + case "NUMERIC_EQUALS": + case "STRING_EQ": return "equals"; + case "TIMESTAMP_GREATER_THAN": return "is later than"; + case "NUMERIC_GREATER_THAN": return "is greater than"; + case "TIMESTAMP_LESS_THAN": return "is earlier than"; + case "NUMERIC_LESS_THAN": return "is less than"; + case "TIMESTAMP_BETWEEN": + case "NUMERIC_BETWEEN": return "is between"; + case "AND": return "And"; + case "OR": return "Or"; + case "NOT": return "Not"; + default: return ruleType.toString(); + } + } + + function renderCategorization() { + return + + + + + { + rule.ruleBasedOn?.type && + + + + } + + {renderRuleOptions()} + + { + depth === 0 && + + + + + + } + + } + > + { + rule.left && + { + rule.left = ruleData; + updateRule(); + }} + /> + } + { + rule.right && + { + rule.right = ruleData; + updateRule(); + }} + /> + } + + } + + function renderRuleOptions() { + switch (rule.rule?.rule) { + case "STRING_REGEX": + case "STRING_EQ": + case "STRING_CONTAINS": return ( + + { + rule.stringValue = e.target.value; + updateRule(); + }} + /> + + ); + case "BOOLEAN_EQ": return ( + + + + + + } + checkedIcon={ + <> + + + + } + onChange={(e) => { + rule.booleanValue = e.target.checked; + updateRule(); + }} + /> + + ); + case "NUMERIC_EQUALS": return ( + + { + rule.numericValue = e.target.value; + updateRule(); + }} + /> + + ) + case "TIMESTAMP_GREATER_THAN": return ( + + { + rule.timestampGreaterThan = newValue; + updateRule(); + }} + /> + + ) + case "NUMERIC_GREATER_THAN": return ( + + { + rule.numericGreaterThan = e.target.value; + updateRule(); + }} + /> + + ); + case "TIMESTAMP_LESS_THAN": return ( + + { + rule.timestampLessThan = newValue; + updateRule(); + }} + /> + + ); + case "NUMERIC_LESS_THAN": return ( + + { + rule.numericLessThan = e.target.value; + updateRule(); + }} + /> + + ) + case "TIMESTAMP_BETWEEN": return ( + <> + + { + rule.timestampGreaterThan = newValue; + updateRule(); + }} + /> + + + and + + + { + rule.timestampLessThan = newValue; + updateRule(); + }} + /> + + + ); + case "NUMERIC_BETWEEN": return ( + <> + + { + rule.numericGreaterThan = e.target.value; + updateRule(); + }} + /> + + + and + + + { + rule.numericLessThan = e.target.value; + updateRule(); + }} + /> + + + ); + default: return ""; // Unimplemented rule type + } + } + + return renderCategorization(); +} \ No newline at end of file diff --git a/frontend/src/components/categories/CategorizationRulesEditor.jsx b/frontend/src/components/categories/CategorizationRulesEditor.jsx new file mode 100644 index 0000000..9caba1e --- /dev/null +++ b/frontend/src/components/categories/CategorizationRulesEditor.jsx @@ -0,0 +1,165 @@ +import {useEffect, useState} from "react"; +import utils from "@/utils.js"; +import toast from "react-hot-toast"; +import Grid from "@mui/material/Unstable_Grid2"; +import Button from "@mui/material/Button"; +import { + Add as AddIcon, + ChevronRight, + ExpandMore, + Save as SaveIcon +} from "@mui/icons-material"; +import {TreeView} from "@mui/x-tree-view"; +import CategorizationRule from "@/components/categories/CategorizationRule.jsx"; +import {MenuItem, Select} from "@mui/material"; +import Typography from "@mui/material/Typography"; + +export default function CategorizationRulesEditor({selectedCategory, onRuleBehaviorSelect, onSave}) { + const [ruleTypes, setRuleTypes] = useState([]); + const [fields, setFields] = useState([]); + const [rules, setRules] = useState([]); + + useEffect(() => { + utils.showSpinner(); + + toast.promise( + Promise.all([ + utils.performRequest("/api/categories/rules") + .then(resp => resp.json()) + .then(({result}) => setRuleTypes(result)), + utils.performRequest("/api/processed-transactions/fields") + .then(resp => resp.json()) + .then(({result}) => setFields(result)) + ]), + { + loading: "Loading...", + success: () => { + utils.hideSpinner(); + + return "Ready"; + }, + error: (err) => { + utils.hideSpinner(); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ) + }, []); + + useEffect(() => { + utils.showSpinner(); + + utils.performRequest(`/api/categories/${selectedCategory.id}/rules`) + .then(resp => resp.json()) + .then(({result}) => { + setRules(result); + + utils.hideSpinner(); + }) + }, [selectedCategory]); + + function createNewRule(e) { + setRules(rules.concat({ + id: utils.generateUUID() + })); + } + + console.log(rules); + + function saveRules() { + toast.promise( + utils.performRequest(`/api/categories/${selectedCategory.id}/rules`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(rules.map(rule => mapRule(rule))) + }).then(resp => onSave()), + { + loading: "Saving...", + success: "Saved", + error: (err) => `Uh oh, something went wrong: ${err}` + } + ) + } + + function mapRule(rule) { + return { + rule: rule.rule.rule, + ruleBasedOn: rule.ruleBasedOn?.field, + booleanValue: rule.booleanValue, + stringValue: rule.stringValue, + numericValue: rule.numericValue, + numericGreaterThan: rule.numericGreaterThan, + numericLessThan: rule.numericLessThan, + timestampGreaterThan: rule.timestampGreaterThan, + timestampLessThan: rule.timestampLessThan, + left: rule.left ? mapRule(rule.left) : null, + right: rule.right ? mapRule(rule.right) : null + }; + } + + return ( + + + + + + + + + + + + + + + + {rules.map((r, i) => ( + + } + defaultExpandIcon={} + > + setRules(rules.filter(rr => rr.id !== r.id))} + updateRuleData={(ruleData) => { + setRules(rules.map(rr => rr.id === r.id ? ruleData : rr)) + }} + /> + + + ))} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/statements/StatementCard.jsx b/frontend/src/components/statements/StatementCard.jsx index 65ec263..23fb35e 100644 --- a/frontend/src/components/statements/StatementCard.jsx +++ b/frontend/src/components/statements/StatementCard.jsx @@ -20,7 +20,7 @@ export default function StatementCard({ name, timeUploaded, id, onMap, onDelete - diff --git a/frontend/src/components/statements/StatementMappingEditor.jsx b/frontend/src/components/statements/StatementMappingEditor.jsx index a068b72..e080e5c 100644 --- a/frontend/src/components/statements/StatementMappingEditor.jsx +++ b/frontend/src/components/statements/StatementMappingEditor.jsx @@ -11,6 +11,7 @@ import {useEffect, useState} from "react"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; import toast from "react-hot-toast"; +import Typography from "@mui/material/Typography"; const FIELD_TYPES = [ "STRING", @@ -56,6 +57,8 @@ export default function StatementMappingEditor({statementId}) { const [existingMappings, setExistingMappings] = useState([]); useEffect(() => { + utils.showSpinner(); + let supportedConversionsPromise = utils.performRequest("/api/statements/supported-conversions") .then(resp => resp.json()) .then(({result}) => setSupportedConversions(result)); @@ -73,7 +76,8 @@ export default function StatementMappingEditor({statementId}) { .then(({result}) => setFields(result)); toast.promise( - Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise]), + Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise]) + .then(r => utils.hideSpinner()), { loading: "Preparing...", success: "Ready", @@ -217,7 +221,7 @@ export default function StatementMappingEditor({statementId}) { onChangeStringToBooleanTrueValue(e, mapping)} value={mapping.conversion.selected.trueBranchStringValue} - /> = true, else false + />= true, else false ) default: return (

Unsupported

) @@ -260,16 +264,24 @@ export default function StatementMappingEditor({statementId}) { - Statement Column - Conversion - Transaction Field + + Statement Column + + + Conversion + + + Transaction Field + { mappings.map(m => ( - {m.valueGroup.name} + + {m.valueGroup.name} + diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 559150e..963e3fe 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,8 +1,10 @@ +import { v4 } from 'uuid'; + let utils = { performRequest: async (url, options) => { return await fetch(url, options).then(resp => { if (resp.status === 401) { - window.location.replace("https://localhost:8080/oauth2/authorization/authentik") + window.location.replace(`${window.location.origin}/oauth2/authorization/authentik`) throw "Unauthorized, please login."; } @@ -14,8 +16,23 @@ let utils = { return resp; }); }, + isSpinnerShown: () => { + return localStorage.getItem("SpinnerShowing") === "true"; + }, + showSpinner: () => { + localStorage.setItem("SpinnerShowing", "true"); + window.dispatchEvent(new Event("onSpinnerStatusChange")); + }, + hideSpinner: () => { + localStorage.removeItem("SpinnerShowing"); + window.dispatchEvent(new Event("onSpinnerStatusChange")); + }, toPascalCase: (s) => { return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase()); + }, + generateUUID: () => v4(), + isNumeric: (value) => { + return /^-?\d+(\.\d+)?$/.test(value); } }