mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-19 14:19:52 +03:00
Categorization editor, categories page, and working categorization algorithm
This commit is contained in:
parent
03d5d23a03
commit
94daac1600
26 changed files with 1326 additions and 127 deletions
|
@ -4,6 +4,7 @@ import dev.mvvasilev.common.controller.AbstractRestController;
|
||||||
import dev.mvvasilev.common.web.APIResponseDTO;
|
import dev.mvvasilev.common.web.APIResponseDTO;
|
||||||
import dev.mvvasilev.common.web.CrudResponseDTO;
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
import dev.mvvasilev.finances.dtos.*;
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||||
import dev.mvvasilev.finances.services.CategoryService;
|
import dev.mvvasilev.finances.services.CategoryService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
@ -24,6 +26,16 @@ public class CategoriesController extends AbstractRestController {
|
||||||
this.categoryService = categoryService;
|
this.categoryService = categoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
public ResponseEntity<APIResponseDTO<Collection<CategorizationRuleDTO>>> fetchCategorizationRules() {
|
||||||
|
return ok(
|
||||||
|
Arrays.stream(CategorizationRule.values()).map(r -> new CategorizationRuleDTO(
|
||||||
|
r,
|
||||||
|
r.applicableForType()
|
||||||
|
)).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategory(
|
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategory(
|
||||||
@RequestBody CreateCategoryDTO dto,
|
@RequestBody CreateCategoryDTO dto,
|
||||||
|
@ -60,11 +72,12 @@ public class CategoriesController extends AbstractRestController {
|
||||||
|
|
||||||
@PostMapping("/{categoryId}/rules")
|
@PostMapping("/{categoryId}/rules")
|
||||||
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||||
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createCategorizationRule(
|
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createCategorizationRules(
|
||||||
@PathVariable("categoryId") Long categoryId,
|
@PathVariable("categoryId") Long categoryId,
|
||||||
@RequestBody Collection<CreateCategorizationDTO> dto
|
@RequestBody Collection<CreateCategorizationDTO> dto,
|
||||||
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
return created(categoryService.createCategorizationRules(categoryId, dto));
|
return created(categoryService.createCategorizationRules(categoryId, Integer.parseInt(authentication.getName()), dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
// @DeleteMapping("/{categoryId}/rules/{ruleId}")
|
// @DeleteMapping("/{categoryId}/rules/{ruleId}")
|
||||||
|
|
|
@ -7,7 +7,9 @@ import java.time.LocalDateTime;
|
||||||
public record CategorizationDTO(
|
public record CategorizationDTO(
|
||||||
Long id,
|
Long id,
|
||||||
|
|
||||||
CategorizationRule rule,
|
CategorizationRuleDTO rule,
|
||||||
|
|
||||||
|
ProcessedTransactionFieldDTO ruleBasedOn,
|
||||||
|
|
||||||
String stringValue,
|
String stringValue,
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
) {}
|
|
@ -1,7 +1,10 @@
|
||||||
package dev.mvvasilev.finances.dtos;
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||||
|
|
||||||
public record CategoryDTO(
|
public record CategoryDTO(
|
||||||
Long id,
|
Long id,
|
||||||
String name
|
String name,
|
||||||
|
CategorizationRuleBehavior ruleBehavior
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,36 @@
|
||||||
package dev.mvvasilev.finances.dtos;
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
import dev.mvvasilev.finances.enums.CategorizationRule;
|
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||||
|
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||||
|
import jakarta.annotation.Nullable;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Null;
|
|
||||||
import org.hibernate.validator.constraints.Length;
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public record CreateCategorizationDTO (
|
public record CreateCategorizationDTO (
|
||||||
@NotNull
|
@NotNull
|
||||||
CategorizationRule rule,
|
CategorizationRule rule,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
ProcessedTransactionField ruleBasedOn,
|
||||||
|
|
||||||
@Length(max = 1024)
|
@Length(max = 1024)
|
||||||
String stringValue,
|
Optional<String> stringValue,
|
||||||
|
|
||||||
Double numericGreaterThan,
|
Optional<Double> numericGreaterThan,
|
||||||
|
|
||||||
Double numericLessThan,
|
Optional<Double> numericLessThan,
|
||||||
|
|
||||||
Double numericValue,
|
Optional<Double> numericValue,
|
||||||
|
|
||||||
LocalDateTime timestampGreaterThan,
|
Optional<LocalDateTime> timestampGreaterThan,
|
||||||
|
|
||||||
LocalDateTime timestampLessThan,
|
Optional<LocalDateTime> timestampLessThan,
|
||||||
|
|
||||||
Boolean booleanValue,
|
Optional<Boolean> booleanValue,
|
||||||
|
|
||||||
@Valid
|
@Valid
|
||||||
CreateCategorizationDTO left,
|
CreateCategorizationDTO left,
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
package dev.mvvasilev.finances.dtos;
|
package dev.mvvasilev.finances.dtos;
|
||||||
|
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import org.hibernate.validator.constraints.Length;
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
public record UpdateCategoryDTO (
|
public record UpdateCategoryDTO (
|
||||||
@NotNull
|
@NotNull
|
||||||
@Length(max = 255)
|
@Length(max = 255)
|
||||||
String name
|
String name,
|
||||||
|
@NotNull
|
||||||
|
CategorizationRuleBehavior ruleBehavior
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,17 +24,17 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
private String stringValue;
|
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 timestampGreaterThan;
|
||||||
|
|
||||||
private LocalDateTime timestampLessThan;
|
private LocalDateTime timestampLessThan;
|
||||||
|
|
||||||
private boolean booleanValue;
|
private Boolean BooleanValue;
|
||||||
|
|
||||||
private Long categoryId;
|
private Long categoryId;
|
||||||
|
|
||||||
|
@ -78,27 +78,27 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
||||||
this.stringValue = stringValue;
|
this.stringValue = stringValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getNumericGreaterThan() {
|
public Double getNumericGreaterThan() {
|
||||||
return numericGreaterThan;
|
return numericGreaterThan;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setNumericGreaterThan(double numericGreaterThan) {
|
public void setNumericGreaterThan(Double numericGreaterThan) {
|
||||||
this.numericGreaterThan = numericGreaterThan;
|
this.numericGreaterThan = numericGreaterThan;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getNumericLessThan() {
|
public Double getNumericLessThan() {
|
||||||
return numericLessThan;
|
return numericLessThan;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setNumericLessThan(double numericLessThan) {
|
public void setNumericLessThan(Double numericLessThan) {
|
||||||
this.numericLessThan = numericLessThan;
|
this.numericLessThan = numericLessThan;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getNumericValue() {
|
public Double getNumericValue() {
|
||||||
return numericValue;
|
return numericValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setNumericValue(double numericValue) {
|
public void setNumericValue(Double numericValue) {
|
||||||
this.numericValue = numericValue;
|
this.numericValue = numericValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,12 +118,12 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
||||||
this.timestampLessThan = timestampLessThan;
|
this.timestampLessThan = timestampLessThan;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean getBooleanValue() {
|
public Boolean getBooleanValue() {
|
||||||
return booleanValue;
|
return BooleanValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setBooleanValue(boolean booleanValue) {
|
public void setBooleanValue(Boolean BooleanValue) {
|
||||||
this.booleanValue = booleanValue;
|
this.BooleanValue = BooleanValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getCategoryId() {
|
public Long getCategoryId() {
|
||||||
|
|
|
@ -2,6 +2,8 @@ package dev.mvvasilev.finances.entity;
|
||||||
|
|
||||||
import dev.mvvasilev.common.data.AbstractEntity;
|
import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
import dev.mvvasilev.common.data.UserOwned;
|
import dev.mvvasilev.common.data.UserOwned;
|
||||||
|
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||||
|
import jakarta.persistence.Convert;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@ -13,6 +15,9 @@ public class TransactionCategory extends AbstractEntity implements UserOwned {
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Convert(converter = CategorizationRuleBehavior.JpaConverter.class)
|
||||||
|
private CategorizationRuleBehavior ruleBehavior;
|
||||||
|
|
||||||
public TransactionCategory() {
|
public TransactionCategory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,4 +36,12 @@ public class TransactionCategory extends AbstractEntity implements UserOwned {
|
||||||
public void setName(String name) {
|
public void setName(String name) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CategorizationRuleBehavior getRuleBehavior() {
|
||||||
|
return ruleBehavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRuleBehavior(CategorizationRuleBehavior ruleBehavior) {
|
||||||
|
this.ruleBehavior = ruleBehavior;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<String> {
|
||||||
|
ANY,
|
||||||
|
ALL,
|
||||||
|
NONE;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String value() {
|
||||||
|
return name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class JpaConverter extends AbstractEnumConverter<CategorizationRuleBehavior, String> {
|
||||||
|
public JpaConverter() {
|
||||||
|
super(CategorizationRuleBehavior.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,27 +25,21 @@ public interface CategorizationRepository extends JpaRepository<Categorization,
|
||||||
)
|
)
|
||||||
Collection<Categorization> fetchForUser(@Param("userId") int userId);
|
Collection<Categorization> fetchForUser(@Param("userId") int userId);
|
||||||
|
|
||||||
// TODO: Use Recursive CTE
|
|
||||||
@Query(
|
@Query(
|
||||||
value = """
|
value = """
|
||||||
WITH RECURSIVE cats AS (
|
WITH RECURSIVE
|
||||||
SELECT cat.*
|
childCats AS (
|
||||||
FROM categories.categorization AS cat
|
SELECT root.*
|
||||||
WHERE cat.category_id = :categoryId
|
FROM categories.categorization AS root
|
||||||
|
WHERE root.category_id = :categoryId
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
SELECT l.*
|
SELECT c.*
|
||||||
FROM categories.categorization AS l
|
FROM categories.categorization AS c, childCats
|
||||||
JOIN cats ON cats.`left` = l.id
|
WHERE childCats.right_categorization_id = c.id OR childCats.left_categorization_id = c.id
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT r.*
|
|
||||||
FROM categories.categorization AS r
|
|
||||||
JOIN cats ON cats.`right` = r.id
|
|
||||||
)
|
)
|
||||||
SELECT * FROM cats;
|
SELECT DISTINCT * FROM childCats;
|
||||||
""",
|
""",
|
||||||
nativeQuery = true
|
nativeQuery = true
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence;
|
||||||
|
|
||||||
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
||||||
import dev.mvvasilev.finances.entity.TransactionCategory;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
@ -15,7 +16,16 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
|
||||||
@Query(value = "SELECT * FROM categories.transaction_category WHERE user_id = :userId", nativeQuery = true)
|
@Query(value = "SELECT * FROM categories.transaction_category WHERE user_id = :userId", nativeQuery = true)
|
||||||
Collection<TransactionCategory> fetchTransactionCategoriesWithUserId(@Param("userId") int userId);
|
Collection<TransactionCategory> 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
|
@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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,7 @@ import dev.mvvasilev.common.data.AbstractEntity;
|
||||||
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
import dev.mvvasilev.common.exceptions.CommonFinancesException;
|
||||||
import dev.mvvasilev.common.web.CrudResponseDTO;
|
import dev.mvvasilev.common.web.CrudResponseDTO;
|
||||||
import dev.mvvasilev.finances.dtos.*;
|
import dev.mvvasilev.finances.dtos.*;
|
||||||
import dev.mvvasilev.finances.entity.Categorization;
|
import dev.mvvasilev.finances.entity.*;
|
||||||
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
|
||||||
import dev.mvvasilev.finances.entity.ProcessedTransactionCategory;
|
|
||||||
import dev.mvvasilev.finances.entity.TransactionCategory;
|
|
||||||
import dev.mvvasilev.finances.persistence.CategorizationRepository;
|
import dev.mvvasilev.finances.persistence.CategorizationRepository;
|
||||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
||||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
||||||
|
@ -18,10 +15,7 @@ import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
import java.util.*;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@ -64,14 +58,15 @@ public class CategoryService {
|
||||||
public Collection<CategoryDTO> listForUser(int userId) {
|
public Collection<CategoryDTO> listForUser(int userId) {
|
||||||
return transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId)
|
return transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(entity -> new CategoryDTO(entity.getId(), entity.getName()))
|
.map(entity -> new CategoryDTO(entity.getId(), entity.getName(), entity.getRuleBehavior()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public int update(Long categoryId, UpdateCategoryDTO dto) {
|
public int update(Long categoryId, UpdateCategoryDTO dto) {
|
||||||
return transactionCategoryRepository.updateTransactionCategoryName(
|
return transactionCategoryRepository.updateTransactionCategoryName(
|
||||||
categoryId,
|
categoryId,
|
||||||
dto.name()
|
dto.name(),
|
||||||
|
dto.ruleBehavior()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,28 +76,53 @@ public class CategoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void categorizeForUser(int userId) {
|
public void categorizeForUser(int userId) {
|
||||||
|
final var categories = transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId);
|
||||||
final var categorizations = categorizationRepository.fetchForUser(userId);
|
final var categorizations = categorizationRepository.fetchForUser(userId);
|
||||||
final var transactions = processedTransactionRepository.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()
|
final var futures = categorizations.stream()
|
||||||
.map(c -> CompletableFuture.supplyAsync(() ->
|
.collect(Collectors.groupingBy(Categorization::getCategoryId, HashMap::new, Collectors.toList()))
|
||||||
transactions.stream()
|
.entrySet()
|
||||||
.map((transaction) -> categorizeTransaction(categorizations, c, transaction))
|
.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.<ProcessedTransactionCategory>empty();
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(Optional::isPresent)
|
.filter(Optional::isPresent)
|
||||||
.map(Optional::get)
|
.map(Optional::get)
|
||||||
.toList())
|
.toList();
|
||||||
)
|
}))
|
||||||
.toArray(length -> (CompletableFuture<List<ProcessedTransactionCategory>>[]) new CompletableFuture[length]);
|
.toArray(length -> (CompletableFuture<List<ProcessedTransactionCategory>>[]) new CompletableFuture[length]);
|
||||||
|
|
||||||
// Run them all in parallel
|
// Run them all in parallel
|
||||||
final var categories = CompletableFuture.allOf(futures).thenApply((v) ->
|
final var ptcs = CompletableFuture.allOf(futures).thenApply((v) ->
|
||||||
Arrays.stream(futures)
|
Arrays.stream(futures)
|
||||||
.flatMap(future -> future.join().stream())
|
.flatMap(future -> future.join().stream())
|
||||||
.toList()
|
.toList()
|
||||||
).join();
|
).join();
|
||||||
|
|
||||||
processedTransactionCategoryRepository.saveAllAndFlush(categories);
|
processedTransactionCategoryRepository.saveAllAndFlush(ptcs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<ProcessedTransactionCategory> categorizeTransaction(final Collection<Categorization> allCategorizations, Categorization categorization, ProcessedTransaction processedTransaction) {
|
private Optional<ProcessedTransactionCategory> categorizeTransaction(final Collection<Categorization> allCategorizations, Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||||
|
@ -247,45 +267,79 @@ public class CategoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
|
public Collection<CategorizationDTO> 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<Long> createCategorizationRules(Long categoryId, Collection<CreateCategorizationDTO> dtos) {
|
private CategorizationDTO mapCategorization(final Collection<Categorization> 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<Long> createCategorizationRules(Long categoryId, Integer userId, Collection<CreateCategorizationDTO> dtos) {
|
||||||
categorizationRepository.deleteAllForCategory(categoryId);
|
categorizationRepository.deleteAllForCategory(categoryId);
|
||||||
|
|
||||||
final var newCategorizations = dtos.stream()
|
return dtos.stream()
|
||||||
.map(dto -> saveCategorizationRule(categoryId, dto))
|
.map(dto -> saveCategorizationRule(categoryId, userId, dto).getId())
|
||||||
.toList();
|
|
||||||
|
|
||||||
return categorizationRepository.saveAllAndFlush(newCategorizations).stream()
|
|
||||||
.map(AbstractEntity::getId)
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Categorization saveCategorizationRule(Long categoryId, CreateCategorizationDTO dto) {
|
private Categorization saveCategorizationRule(Long categoryId, Integer userId, CreateCategorizationDTO dto) {
|
||||||
// TODO: Avoid recursion
|
// TODO: Avoid recursion
|
||||||
|
|
||||||
final var categorization = new Categorization();
|
final var categorization = new Categorization();
|
||||||
|
|
||||||
categorization.setCategorizationRule(dto.rule());
|
categorization.setCategorizationRule(dto.rule());
|
||||||
categorization.setCategoryId(null);
|
categorization.setUserId(userId);
|
||||||
categorization.setStringValue(dto.stringValue());
|
categorization.setRuleBasedOn(dto.ruleBasedOn());
|
||||||
categorization.setNumericGreaterThan(dto.numericGreaterThan());
|
categorization.setCategoryId(categoryId);
|
||||||
categorization.setNumericLessThan(dto.numericLessThan());
|
categorization.setStringValue(dto.stringValue().orElse(null));
|
||||||
categorization.setNumericValue(dto.numericValue());
|
categorization.setNumericGreaterThan(dto.numericGreaterThan().orElse(null));
|
||||||
categorization.setTimestampGreaterThan(dto.timestampGreaterThan());
|
categorization.setNumericLessThan(dto.numericLessThan().orElse(null));
|
||||||
categorization.setTimestampLessThan(dto.timestampLessThan());
|
categorization.setNumericValue(dto.numericValue().orElse(null));
|
||||||
categorization.setBooleanValue(dto.booleanValue());
|
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
|
// Only root rules have category id set, to differentiate them from non-roots
|
||||||
// TODO: This smells bad. Add an isRoot property instead?
|
// TODO: This smells bad. Add an isRoot property instead?
|
||||||
if (dto.left() != null) {
|
if (dto.left() != null) {
|
||||||
final var leftCat = saveCategorizationRule(null, dto.left());
|
final var leftCat = saveCategorizationRule(null, userId, dto.left());
|
||||||
categorization.setLeftCategorizationId(leftCat.getId());
|
categorization.setLeftCategorizationId(leftCat.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.right() != null) {
|
if (dto.right() != null) {
|
||||||
final var rightCat = saveCategorizationRule(null, dto.right());
|
final var rightCat = saveCategorizationRule(null, userId, dto.right());
|
||||||
categorization.setRightCategorizationId(rightCat.getId());
|
categorization.setRightCategorizationId(rightCat.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE categories.transaction_category ADD COLUMN IF NOT EXISTS rule_behavior VARCHAR(255);
|
74
frontend/package-lock.json
generated
74
frontend/package-lock.json
generated
|
@ -14,12 +14,15 @@
|
||||||
"@mui/icons-material": "^5.15.0",
|
"@mui/icons-material": "^5.15.0",
|
||||||
"@mui/material": "^5.15.0",
|
"@mui/material": "^5.15.0",
|
||||||
"@mui/x-data-grid": "^6.18.6",
|
"@mui/x-data-grid": "^6.18.6",
|
||||||
|
"@mui/x-date-pickers": "^6.18.6",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-material-ui-carousel": "^3.4.2",
|
"react-material-ui-carousel": "^3.4.2",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9"
|
"vis-network": "^9.1.9"
|
||||||
},
|
},
|
||||||
|
@ -1372,6 +1375,71 @@
|
||||||
"react-dom": "^17.0.0 || ^18.0.0"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
|
@ -4672,7 +4745,6 @@
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,15 @@
|
||||||
"@mui/icons-material": "^5.15.0",
|
"@mui/icons-material": "^5.15.0",
|
||||||
"@mui/material": "^5.15.0",
|
"@mui/material": "^5.15.0",
|
||||||
"@mui/x-data-grid": "^6.18.6",
|
"@mui/x-data-grid": "^6.18.6",
|
||||||
|
"@mui/x-date-pickers": "^6.18.6",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-material-ui-carousel": "^3.4.2",
|
"react-material-ui-carousel": "^3.4.2",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9"
|
"vis-network": "^9.1.9"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import HomePage from "@/app/pages/HomePage"
|
|
||||||
import RootLayout from '@/app/Layout';
|
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 TransactionsPage from "@/app/pages/TransactionsPage.jsx";
|
||||||
|
import CategoriesPage from "@/app/pages/CategoriesPage.jsx";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RootLayout>
|
<RootLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<StatisticsPage />} />
|
||||||
<Route path="/statements" element={<StatementsPage />} />
|
<Route path="/statements" element={<StatementsPage />} />
|
||||||
<Route path="/transactions" element={<TransactionsPage />} />
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -18,6 +18,11 @@ import {Logout as LogoutIcon} from '@mui/icons-material';
|
||||||
import {Login as LoginIcon} from "@mui/icons-material";
|
import {Login as LoginIcon} from "@mui/icons-material";
|
||||||
import {Toaster} from 'react-hot-toast';
|
import {Toaster} from 'react-hot-toast';
|
||||||
import theme from '../components/ThemeRegistry/theme';
|
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;
|
const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
@ -49,10 +54,38 @@ function isLoggedIn() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({children}) {
|
export default function RootLayout({children}) {
|
||||||
|
|
||||||
|
const [spinner, showSpinner] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("onSpinnerStatusChange", () => {
|
||||||
|
showSpinner(utils.isSpinnerShown());
|
||||||
|
})
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline/>
|
<CssBaseline/>
|
||||||
|
|
||||||
|
{
|
||||||
|
<Backdrop
|
||||||
|
sx={{
|
||||||
|
color: '#fff',
|
||||||
|
zIndex: 2147483647
|
||||||
|
}}
|
||||||
|
open={spinner}
|
||||||
|
>
|
||||||
|
<CircularProgress
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2147483647,
|
||||||
|
top: "50%",
|
||||||
|
left: "50%"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Backdrop>
|
||||||
|
}
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
sx={{
|
sx={{
|
||||||
width: DRAWER_WIDTH,
|
width: DRAWER_WIDTH,
|
||||||
|
@ -109,6 +142,7 @@ export default function RootLayout({children}) {
|
||||||
</form>}
|
</form>}
|
||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
|
@ -123,6 +157,7 @@ export default function RootLayout({children}) {
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
</LocalizationProvider>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|
335
frontend/src/app/pages/CategoriesPage.jsx
Normal file
335
frontend/src/app/pages/CategoriesPage.jsx
Normal file
|
@ -0,0 +1,335 @@
|
||||||
|
import {TreeItem, TreeView} from "@mui/x-tree-view";
|
||||||
|
import {
|
||||||
|
Delete,
|
||||||
|
Category as CategoryIcon,
|
||||||
|
Add as AddIcon,
|
||||||
|
Close as CloseIcon,
|
||||||
|
Save as SaveIcon
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
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 Divider from "@mui/material/Divider";
|
||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextField
|
||||||
|
} from "@mui/material";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CategorizationRulesEditor from "@/components/categories/CategorizationRulesEditor.jsx";
|
||||||
|
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [isCategoryModalOpen, openCategoryModal] = useState(false);
|
||||||
|
const [showConfirmDeleteCategoryModal, openConfirmDeleteCategoryModal] = useState(false);
|
||||||
|
const [showApplyRulesConfirmModal, openApplyRulesConfirmModal] = useState(false);
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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 (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
|
||||||
|
<Grid container xs={12} lg={12}>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Button sx={{ width:"100%" }} variant="contained" startIcon={<AddIcon />} onClick={() => openCategoryModal(true)}>
|
||||||
|
Add Category
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<CategoryIcon />} onClick={() => openApplyRulesConfirmModal(true)}>
|
||||||
|
Apply Rules
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={10} lg={10}></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
overflowY: "scroll"
|
||||||
|
}}
|
||||||
|
minHeight={"100px"}
|
||||||
|
maxHeight={"250px"}
|
||||||
|
useFlexGap
|
||||||
|
flexWrap="wrap"
|
||||||
|
direction={"row"}
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
categories.map(c => {
|
||||||
|
let variant = (selectedCategory?.id ?? -1) === c.id ? "filled" : "outlined";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={c.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
setSelectedCategory({...c});
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
setSelectedCategory(c);
|
||||||
|
openConfirmDeleteCategoryModal(true);
|
||||||
|
}}
|
||||||
|
label={c.name}
|
||||||
|
deleteIcon={<Delete/>}
|
||||||
|
variant={variant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
<Divider></Divider>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
{
|
||||||
|
selectedCategory &&
|
||||||
|
<CategorizationRulesEditor
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onRuleBehaviorSelect={(value) => {
|
||||||
|
selectedCategory.ruleBehavior = value;
|
||||||
|
setSelectedCategory({...selectedCategory});
|
||||||
|
}}
|
||||||
|
onSave={() => saveCategory(selectedCategory)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 400,
|
||||||
|
height: "fit-content",
|
||||||
|
p: 4
|
||||||
|
}}
|
||||||
|
open={isCategoryModalOpen}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<h3>Create New Category</h3>
|
||||||
|
<Divider></Divider>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid xs={12} lg={12}>
|
||||||
|
<TextField
|
||||||
|
id="category-name"
|
||||||
|
label="Category Name"
|
||||||
|
variant="outlined"
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
sx={{width: "100%"}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={6} lg={6}>
|
||||||
|
<Button
|
||||||
|
sx={{width: "100%"}}
|
||||||
|
variant="contained"
|
||||||
|
onClick={createNewCategory}
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={6} lg={6}>
|
||||||
|
<Button
|
||||||
|
sx={{width: "100%"}}
|
||||||
|
onClick={() => openCategoryModal(false)}
|
||||||
|
startIcon={<CloseIcon />}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
<Dialog
|
||||||
|
open={showConfirmDeleteCategoryModal}
|
||||||
|
>
|
||||||
|
<DialogTitle id="delete-category-dialog-title">
|
||||||
|
{`Delete Category "${selectedCategory?.name}"?`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Deleting a category will also clear it from all transactions it is currently applied to
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={deleteSelectedCategory}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => openConfirmDeleteCategoryModal(false)}
|
||||||
|
autoFocus
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={showApplyRulesConfirmModal}
|
||||||
|
>
|
||||||
|
<DialogTitle id="apply-rules-dialog-title">
|
||||||
|
{"Apply all categorization rules?"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
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.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={applyCategorizationRules}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => openApplyRulesConfirmModal(false)}
|
||||||
|
autoFocus
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import {Stack} from "@mui/material";
|
||||||
import StatementCard from "@/components/statements/StatementCard.jsx";
|
import StatementCard from "@/components/statements/StatementCard.jsx";
|
||||||
import Carousel from "react-material-ui-carousel";
|
import Carousel from "react-material-ui-carousel";
|
||||||
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
|
||||||
|
|
||||||
export default function StatementsPage() {
|
export default function StatementsPage() {
|
||||||
|
@ -22,9 +23,15 @@ export default function StatementsPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function fetchStatements() {
|
function fetchStatements() {
|
||||||
|
utils.showSpinner();
|
||||||
|
|
||||||
utils.performRequest("/api/statements")
|
utils.performRequest("/api/statements")
|
||||||
.then(resp => resp.json())
|
.then(resp => resp.json())
|
||||||
.then(({ result }) => setStatements(result));
|
.then(({ result }) => {
|
||||||
|
setStatements(result);
|
||||||
|
|
||||||
|
utils.hideSpinner();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadStatement({ target }) {
|
async function uploadStatement({ target }) {
|
||||||
|
@ -33,6 +40,8 @@ export default function StatementsPage() {
|
||||||
let formData = new FormData();
|
let formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
|
utils.showSpinner();
|
||||||
|
|
||||||
await toast.promise(
|
await toast.promise(
|
||||||
utils.performRequest("/api/statements/uploadSheet", {
|
utils.performRequest("/api/statements/uploadSheet", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -45,7 +54,11 @@ export default function StatementsPage() {
|
||||||
|
|
||||||
return "Upload successful!";
|
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() {
|
||||||
}
|
}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Divider></Divider>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
{
|
{
|
||||||
mappingStatementId !== -1 &&
|
mappingStatementId !== -1 &&
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Grid from '@mui/material/Unstable_Grid2';
|
||||||
import MediaCard from '@/components/MediaCard';
|
import MediaCard from '@/components/MediaCard';
|
||||||
import { Stack } from '@mui/material';
|
import { Stack } from '@mui/material';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function StatisticsPage() {
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<div>
|
<div>
|
|
@ -3,6 +3,7 @@ import {Stack} from "@mui/material";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {DataGrid} from "@mui/x-data-grid";
|
import {DataGrid} from "@mui/x-data-grid";
|
||||||
import utils from "@/utils.js";
|
import utils from "@/utils.js";
|
||||||
|
import {ArrowDownward, ArrowUpward, PriceChange} from "@mui/icons-material";
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{
|
{
|
||||||
|
@ -12,6 +13,19 @@ const COLUMNS = [
|
||||||
width: 150,
|
width: 150,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
renderCell: (params) => {
|
||||||
|
return params.value ? (
|
||||||
|
<>
|
||||||
|
<PriceChange style={{ color: '#4d4' }} />
|
||||||
|
<ArrowUpward style={{ color: '#4d4' }} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PriceChange style={{ color: '#d44' }} />
|
||||||
|
<ArrowDownward style={{ color: '#d44' }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "amount",
|
field: "amount",
|
||||||
|
@ -21,7 +35,7 @@ const COLUMNS = [
|
||||||
flex: true,
|
flex: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
valueFormatter: val => `${val.value} лв.`
|
valueFormatter: val => `${(val.value).toLocaleString(undefined, { minimumFractionDigits: 2 })} лв.`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "description",
|
field: "description",
|
||||||
|
@ -40,7 +54,7 @@ const COLUMNS = [
|
||||||
flex: true,
|
flex: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: false,
|
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({
|
const [pageOptions, setPageOptions] = useState({
|
||||||
page: 0,
|
page: 0,
|
||||||
pageSize: 100,
|
pageSize: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sortOptions, setSortOptions] = useState([
|
const [sortOptions, setSortOptions] = useState([
|
||||||
|
@ -61,6 +75,8 @@ export default function TransactionsPage() {
|
||||||
const [transactions, setTransactions] = useState({});
|
const [transactions, setTransactions] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
utils.showSpinner();
|
||||||
|
|
||||||
// Multi-sorting requires the MUI data grid pro license :)
|
// Multi-sorting requires the MUI data grid pro license :)
|
||||||
let sortBy = sortOptions.map((sort) => `&sort=${sort.field},${sort.sort}`).join("")
|
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}`)
|
utils.performRequest(`/api/processed-transactions?page=${pageOptions.page}&size=${pageOptions.pageSize}${sortBy}`)
|
||||||
.then(resp => resp.json())
|
.then(resp => resp.json())
|
||||||
.then(({result}) => setTransactions(result));
|
.then(({result}) => {
|
||||||
|
setTransactions(result);
|
||||||
|
utils.hideSpinner();
|
||||||
|
});
|
||||||
}, [pageOptions, sortOptions]);
|
}, [pageOptions, sortOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack
|
||||||
<Grid container columnSpacing={1}>
|
>
|
||||||
<Grid xs={12} lg={12}>
|
<Grid
|
||||||
|
container
|
||||||
|
columnSpacing={1}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
sx={{
|
||||||
|
height: "1200px"
|
||||||
|
}}
|
||||||
|
xs={12}
|
||||||
|
lg={12}
|
||||||
|
>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
|
sx={{
|
||||||
|
overflowY: "scroll"
|
||||||
|
}}
|
||||||
columns={COLUMNS}
|
columns={COLUMNS}
|
||||||
rows={transactions.content ?? []}
|
rows={transactions.content ?? []}
|
||||||
rowCount={transactions.totalElements ?? 0}
|
rowCount={transactions.totalElements ?? 0}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
paginationMode={"server"}
|
paginationMode={"server"}
|
||||||
sortingMode={"server"}
|
sortingMode={"server"}
|
||||||
paginationModel={pageOptions}
|
paginationModel={pageOptions}
|
||||||
|
|
380
frontend/src/components/categories/CategorizationRule.jsx
Normal file
380
frontend/src/components/categories/CategorizationRule.jsx
Normal file
|
@ -0,0 +1,380 @@
|
||||||
|
import {TreeItem} from "@mui/x-tree-view";
|
||||||
|
import Grid from "@mui/material/Unstable_Grid2";
|
||||||
|
import {Checkbox, FormControlLabel, IconButton, MenuItem, Select, TextField} from "@mui/material";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import utils from "@/utils.js";
|
||||||
|
import {ArrowDownward, ArrowUpward, PriceChange, Close as DeleteIcon} from "@mui/icons-material";
|
||||||
|
import {DatePicker} from "@mui/x-date-pickers";
|
||||||
|
import {useState} from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export default function CategorizationRule({ ruleData, fields, ruleTypes, onDelete, updateRuleData, depth: depth = 0 }) {
|
||||||
|
|
||||||
|
const [rule, setRule] = useState(ruleData);
|
||||||
|
|
||||||
|
function selectRuleTypeOrField(value) {
|
||||||
|
let field = fields.find(f => 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 <TreeItem
|
||||||
|
key={`${rule.id}`}
|
||||||
|
sx={{
|
||||||
|
pt: 1,
|
||||||
|
pb: 1
|
||||||
|
}}
|
||||||
|
nodeId={`${rule.id}`}
|
||||||
|
label={
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Select
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
defaultValue={"placeholder"}
|
||||||
|
value={rule.ruleBasedOn?.field ?? rule.rule?.rule ?? "placeholder"}
|
||||||
|
onChange={(e) => selectRuleTypeOrField(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem disabled value="placeholder">
|
||||||
|
<Typography sx={{ color: 'gray' }}>Field/Rule</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
{
|
||||||
|
fieldsAndLogicalOperators().map(item => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${rule.id}-${item.name}-${depth}`}
|
||||||
|
value={item.name}
|
||||||
|
>
|
||||||
|
{ utils.toPascalCase(item.name.replace(/_/g, " ")) }
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{
|
||||||
|
rule.ruleBasedOn?.type &&
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Select
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
defaultValue={"placeholder"}
|
||||||
|
value={rule.rule?.rule ?? "placeholder"}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.rule = ruleTypes.find(rt => rt.rule === e.target.value);
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem disabled value="placeholder">
|
||||||
|
<Typography sx={{ color: 'gray' }}>Rule</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
{
|
||||||
|
ruleTypes.filter(rt => rt.applicableType === rule.ruleBasedOn.type)
|
||||||
|
.map(rt => {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={`${rule.id}-${rt.rule}-${depth}`}
|
||||||
|
value={rt.rule}
|
||||||
|
>
|
||||||
|
{ ruleTypeName(rt.rule) }
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
|
||||||
|
{renderRuleOptions()}
|
||||||
|
|
||||||
|
{
|
||||||
|
depth === 0 &&
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<IconButton
|
||||||
|
sx={{ height: "100%" }}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
rule.left &&
|
||||||
|
<CategorizationRule
|
||||||
|
ruleData={rule.left}
|
||||||
|
fields={fields}
|
||||||
|
ruleTypes={ruleTypes}
|
||||||
|
depth={depth + 1}
|
||||||
|
updateRuleData={(ruleData) => {
|
||||||
|
rule.left = ruleData;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
rule.right &&
|
||||||
|
<CategorizationRule
|
||||||
|
ruleData={rule.right}
|
||||||
|
fields={fields}
|
||||||
|
ruleTypes={ruleTypes}
|
||||||
|
depth={depth + 1}
|
||||||
|
updateRuleData={(ruleData) => {
|
||||||
|
rule.right = ruleData;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</TreeItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuleOptions() {
|
||||||
|
switch (rule.rule?.rule) {
|
||||||
|
case "STRING_REGEX":
|
||||||
|
case "STRING_EQ":
|
||||||
|
case "STRING_CONTAINS": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Value"}
|
||||||
|
value={rule.stringValue ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.stringValue = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
case "BOOLEAN_EQ": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Checkbox
|
||||||
|
sx={{ width: "100%", height: "100%"}}
|
||||||
|
checked={rule.booleanValue ?? false}
|
||||||
|
icon={
|
||||||
|
<>
|
||||||
|
<PriceChange style={{ color: '#d44' }} />
|
||||||
|
<ArrowDownward style={{ color: '#d44' }} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
checkedIcon={
|
||||||
|
<>
|
||||||
|
<PriceChange style={{ color: '#4d4' }} />
|
||||||
|
<ArrowUpward style={{ color: '#4d4' }} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.booleanValue = e.target.checked;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
case "NUMERIC_EQUALS": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Value"}
|
||||||
|
type="number"
|
||||||
|
value={rule.numericValue ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.numericValue = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
case "TIMESTAMP_GREATER_THAN": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<DatePicker
|
||||||
|
sx={{ width: "100% "}}
|
||||||
|
label="Value"
|
||||||
|
value={dayjs(rule.timestampGreaterThan) ?? ""}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
rule.timestampGreaterThan = newValue;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
case "NUMERIC_GREATER_THAN": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Value"}
|
||||||
|
type="number"
|
||||||
|
value={rule.numericGreaterThan ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.numericGreaterThan = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
case "TIMESTAMP_LESS_THAN": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<DatePicker
|
||||||
|
sx={{ width: "100% "}}
|
||||||
|
label="Value"
|
||||||
|
value={dayjs(rule.timestampLessThan) ?? ""}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
rule.timestampLessThan = newValue;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
case "NUMERIC_LESS_THAN": return (
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Value"}
|
||||||
|
type="number"
|
||||||
|
value={rule.numericLessThan ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.numericLessThan = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
case "TIMESTAMP_BETWEEN": return (
|
||||||
|
<>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<DatePicker
|
||||||
|
sx={{ width: "100% "}}
|
||||||
|
label="Value"
|
||||||
|
value={dayjs(rule.timestampGreaterThan) ?? ""}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
rule.timestampGreaterThan = newValue;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1} display="flex" justifyContent="center" alignItems="center">
|
||||||
|
<Typography fontSize={"1.25em"}>and</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<DatePicker
|
||||||
|
sx={{ width: "100% "}}
|
||||||
|
label="Value"
|
||||||
|
value={dayjs(rule.timestampLessThan) ?? ""}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
rule.timestampLessThan = newValue;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "NUMERIC_BETWEEN": return (
|
||||||
|
<>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Greater Than"}
|
||||||
|
type="number"
|
||||||
|
value={rule.numericGreaterThan ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.numericGreaterThan = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1} display="flex" justifyContent="center" alignItems="center">
|
||||||
|
<Typography fontSize={"1.25em"}>and</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<TextField
|
||||||
|
sx={{ width: "100%" }}
|
||||||
|
label={"Less Than"}
|
||||||
|
type="number"
|
||||||
|
value={rule.numericLessThan ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
rule.numericLessThan = e.target.value;
|
||||||
|
updateRule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default: return ""; // Unimplemented rule type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderCategorization();
|
||||||
|
}
|
165
frontend/src/components/categories/CategorizationRulesEditor.jsx
Normal file
165
frontend/src/components/categories/CategorizationRulesEditor.jsx
Normal file
|
@ -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 (
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
|
||||||
|
<Grid container xs={12} lg={12}>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Button
|
||||||
|
sx={{ width: "100%", height: "100%" }}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={createNewRule}
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
sx={{ width: "100%", height: "100%" }}
|
||||||
|
defaultValue={"ANY"}
|
||||||
|
value={selectedCategory.ruleBehavior ?? "ANY"}
|
||||||
|
label={"Rules Behavior"}
|
||||||
|
onChange={(e) => onRuleBehaviorSelect(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="ALL">All</MenuItem>
|
||||||
|
<MenuItem value="ANY">Any</MenuItem>
|
||||||
|
<MenuItem value="NONE">None</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={1} lg={1}>
|
||||||
|
<Button
|
||||||
|
sx={{ width: "100%", height: "100%" }}
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={saveRules}
|
||||||
|
>
|
||||||
|
Save Rules
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid xs={9} lg={9}></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{rules.map((r, i) => (
|
||||||
|
<Grid key={`rule-${i}`} xs={12} lg={12}>
|
||||||
|
<TreeView
|
||||||
|
defaultCollapseIcon={<ExpandMore />}
|
||||||
|
defaultExpandIcon={<ChevronRight />}
|
||||||
|
>
|
||||||
|
<CategorizationRule
|
||||||
|
key={r.id}
|
||||||
|
ruleData={r}
|
||||||
|
fields={fields}
|
||||||
|
ruleTypes={ruleTypes}
|
||||||
|
onDelete={() => setRules(rules.filter(rr => rr.id !== r.id))}
|
||||||
|
updateRuleData={(ruleData) => {
|
||||||
|
setRules(rules.map(rr => rr.id === r.id ? ruleData : rr))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TreeView>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
|
@ -20,7 +20,7 @@ export default function StatementCard({ name, timeUploaded, id, onMap, onDelete
|
||||||
<Button variant="contained" size="small" onClick={(e) => onMap(e, id)} startIcon={<AccountTreeIcon />}>
|
<Button variant="contained" size="small" onClick={(e) => onMap(e, id)} startIcon={<AccountTreeIcon />}>
|
||||||
Map
|
Map
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" size="small" onClick={(e) => onDelete(e, id)} startIcon={<DeleteIcon />}>
|
<Button variant="outlined" color="error" size="small" onClick={(e) => onDelete(e, id)} startIcon={<DeleteIcon />}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {useEffect, useState} from "react";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
const FIELD_TYPES = [
|
const FIELD_TYPES = [
|
||||||
"STRING",
|
"STRING",
|
||||||
|
@ -56,6 +57,8 @@ export default function StatementMappingEditor({statementId}) {
|
||||||
const [existingMappings, setExistingMappings] = useState([]);
|
const [existingMappings, setExistingMappings] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
utils.showSpinner();
|
||||||
|
|
||||||
let supportedConversionsPromise = utils.performRequest("/api/statements/supported-conversions")
|
let supportedConversionsPromise = utils.performRequest("/api/statements/supported-conversions")
|
||||||
.then(resp => resp.json())
|
.then(resp => resp.json())
|
||||||
.then(({result}) => setSupportedConversions(result));
|
.then(({result}) => setSupportedConversions(result));
|
||||||
|
@ -73,7 +76,8 @@ export default function StatementMappingEditor({statementId}) {
|
||||||
.then(({result}) => setFields(result));
|
.then(({result}) => setFields(result));
|
||||||
|
|
||||||
toast.promise(
|
toast.promise(
|
||||||
Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise]),
|
Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise])
|
||||||
|
.then(r => utils.hideSpinner()),
|
||||||
{
|
{
|
||||||
loading: "Preparing...",
|
loading: "Preparing...",
|
||||||
success: "Ready",
|
success: "Ready",
|
||||||
|
@ -217,7 +221,7 @@ export default function StatementMappingEditor({statementId}) {
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => onChangeStringToBooleanTrueValue(e, mapping)}
|
onChange={(e) => onChangeStringToBooleanTrueValue(e, mapping)}
|
||||||
value={mapping.conversion.selected.trueBranchStringValue}
|
value={mapping.conversion.selected.trueBranchStringValue}
|
||||||
/> = true, else false
|
/><Typography display={"inline"}>= true, else false</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
default: return (<p>Unsupported</p>)
|
default: return (<p>Unsupported</p>)
|
||||||
|
@ -260,16 +264,24 @@ export default function StatementMappingEditor({statementId}) {
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell style={{width: "300px"}} align="left" >Statement Column</TableCell>
|
<TableCell style={{width: "300px"}} align="left" >
|
||||||
<TableCell align="left">Conversion</TableCell>
|
<Typography fontSize={"1.25em"}>Statement Column</Typography>
|
||||||
<TableCell style={{width: "300px"}} align="left">Transaction Field</TableCell>
|
</TableCell>
|
||||||
|
<TableCell align="left">
|
||||||
|
<Typography fontSize={"1.25em"}>Conversion</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell style={{width: "300px"}} align="left">
|
||||||
|
<Typography fontSize={"1.25em"}>Transaction Field</Typography>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{
|
{
|
||||||
mappings.map(m => (
|
mappings.map(m => (
|
||||||
<TableRow key={m.valueGroup.id}>
|
<TableRow key={m.valueGroup.id}>
|
||||||
<TableCell>{m.valueGroup.name}</TableCell>
|
<TableCell>
|
||||||
|
<Typography>{m.valueGroup.name}</Typography>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Grid columnSpacing={1} container>
|
<Grid columnSpacing={1} container>
|
||||||
<Grid xs={2} lg={2}>
|
<Grid xs={2} lg={2}>
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
let utils = {
|
let utils = {
|
||||||
performRequest: async (url, options) => {
|
performRequest: async (url, options) => {
|
||||||
return await fetch(url, options).then(resp => {
|
return await fetch(url, options).then(resp => {
|
||||||
if (resp.status === 401) {
|
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.";
|
throw "Unauthorized, please login.";
|
||||||
}
|
}
|
||||||
|
@ -14,8 +16,23 @@ let utils = {
|
||||||
return resp;
|
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) => {
|
toPascalCase: (s) => {
|
||||||
return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase());
|
return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase());
|
||||||
|
},
|
||||||
|
generateUUID: () => v4(),
|
||||||
|
isNumeric: (value) => {
|
||||||
|
return /^-?\d+(\.\d+)?$/.test(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue