Categorization editor, categories page, and working categorization algorithm

This commit is contained in:
Miroslav Vasilev 2023-12-30 12:50:07 +02:00
parent 03d5d23a03
commit 94daac1600
26 changed files with 1326 additions and 127 deletions

View file

@ -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}")

View file

@ -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,

View file

@ -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
) {}

View file

@ -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
) { ) {
} }

View file

@ -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,

View file

@ -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
) { ) {
} }

View file

@ -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() {

View file

@ -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;
}
} }

View file

@ -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);
}
}
}

View file

@ -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
) )

View file

@ -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
);
} }

View file

@ -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());
} }

View file

@ -0,0 +1 @@
ALTER TABLE categories.transaction_category ADD COLUMN IF NOT EXISTS rule_behavior VARCHAR(255);

View file

@ -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"
} }

View file

@ -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"
}, },

View file

@ -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>
</> </>

View file

@ -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={{

View 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>
);
}

View file

@ -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 &&

View file

@ -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>

View file

@ -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}

View 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();
}

View 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>
);
}

View file

@ -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>

View file

@ -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}>

View file

@ -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);
} }
} }