mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-18 21:59: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.CrudResponseDTO;
|
||||
import dev.mvvasilev.finances.dtos.*;
|
||||
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||
import dev.mvvasilev.finances.services.CategoryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
@ -11,6 +12,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
|
||||
@RestController
|
||||
|
@ -24,6 +26,16 @@ public class CategoriesController extends AbstractRestController {
|
|||
this.categoryService = categoryService;
|
||||
}
|
||||
|
||||
@GetMapping("/rules")
|
||||
public ResponseEntity<APIResponseDTO<Collection<CategorizationRuleDTO>>> fetchCategorizationRules() {
|
||||
return ok(
|
||||
Arrays.stream(CategorizationRule.values()).map(r -> new CategorizationRuleDTO(
|
||||
r,
|
||||
r.applicableForType()
|
||||
)).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> createCategory(
|
||||
@RequestBody CreateCategoryDTO dto,
|
||||
|
@ -60,11 +72,12 @@ public class CategoriesController extends AbstractRestController {
|
|||
|
||||
@PostMapping("/{categoryId}/rules")
|
||||
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createCategorizationRule(
|
||||
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> createCategorizationRules(
|
||||
@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}")
|
||||
|
|
|
@ -7,7 +7,9 @@ import java.time.LocalDateTime;
|
|||
public record CategorizationDTO(
|
||||
Long id,
|
||||
|
||||
CategorizationRule rule,
|
||||
CategorizationRuleDTO rule,
|
||||
|
||||
ProcessedTransactionFieldDTO ruleBasedOn,
|
||||
|
||||
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;
|
||||
|
||||
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||
|
||||
public record CategoryDTO(
|
||||
Long id,
|
||||
String name
|
||||
String name,
|
||||
CategorizationRuleBehavior ruleBehavior
|
||||
) {
|
||||
}
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
package dev.mvvasilev.finances.dtos;
|
||||
|
||||
import dev.mvvasilev.finances.enums.CategorizationRule;
|
||||
import dev.mvvasilev.finances.enums.ProcessedTransactionField;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Null;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public record CreateCategorizationDTO (
|
||||
@NotNull
|
||||
CategorizationRule rule,
|
||||
|
||||
@NotNull
|
||||
ProcessedTransactionField ruleBasedOn,
|
||||
|
||||
@Length(max = 1024)
|
||||
String stringValue,
|
||||
Optional<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
|
||||
CreateCategorizationDTO left,
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
package dev.mvvasilev.finances.dtos;
|
||||
|
||||
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
public record UpdateCategoryDTO (
|
||||
@NotNull
|
||||
@Length(max = 255)
|
||||
String name
|
||||
String name,
|
||||
@NotNull
|
||||
CategorizationRuleBehavior ruleBehavior
|
||||
) {
|
||||
}
|
||||
|
|
|
@ -24,17 +24,17 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
|||
|
||||
private String stringValue;
|
||||
|
||||
private double numericGreaterThan;
|
||||
private Double numericGreaterThan;
|
||||
|
||||
private double numericLessThan;
|
||||
private Double numericLessThan;
|
||||
|
||||
private double numericValue;
|
||||
private Double numericValue;
|
||||
|
||||
private LocalDateTime timestampGreaterThan;
|
||||
|
||||
private LocalDateTime timestampLessThan;
|
||||
|
||||
private boolean booleanValue;
|
||||
private Boolean BooleanValue;
|
||||
|
||||
private Long categoryId;
|
||||
|
||||
|
@ -78,27 +78,27 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
|||
this.stringValue = stringValue;
|
||||
}
|
||||
|
||||
public double getNumericGreaterThan() {
|
||||
public Double getNumericGreaterThan() {
|
||||
return numericGreaterThan;
|
||||
}
|
||||
|
||||
public void setNumericGreaterThan(double numericGreaterThan) {
|
||||
public void setNumericGreaterThan(Double numericGreaterThan) {
|
||||
this.numericGreaterThan = numericGreaterThan;
|
||||
}
|
||||
|
||||
public double getNumericLessThan() {
|
||||
public Double getNumericLessThan() {
|
||||
return numericLessThan;
|
||||
}
|
||||
|
||||
public void setNumericLessThan(double numericLessThan) {
|
||||
public void setNumericLessThan(Double numericLessThan) {
|
||||
this.numericLessThan = numericLessThan;
|
||||
}
|
||||
|
||||
public double getNumericValue() {
|
||||
public Double getNumericValue() {
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
public void setNumericValue(double numericValue) {
|
||||
public void setNumericValue(Double numericValue) {
|
||||
this.numericValue = numericValue;
|
||||
}
|
||||
|
||||
|
@ -118,12 +118,12 @@ public class Categorization extends AbstractEntity implements UserOwned {
|
|||
this.timestampLessThan = timestampLessThan;
|
||||
}
|
||||
|
||||
public boolean getBooleanValue() {
|
||||
return booleanValue;
|
||||
public Boolean getBooleanValue() {
|
||||
return BooleanValue;
|
||||
}
|
||||
|
||||
public void setBooleanValue(boolean booleanValue) {
|
||||
this.booleanValue = booleanValue;
|
||||
public void setBooleanValue(Boolean BooleanValue) {
|
||||
this.BooleanValue = BooleanValue;
|
||||
}
|
||||
|
||||
public Long getCategoryId() {
|
||||
|
|
|
@ -2,6 +2,8 @@ package dev.mvvasilev.finances.entity;
|
|||
|
||||
import dev.mvvasilev.common.data.AbstractEntity;
|
||||
import dev.mvvasilev.common.data.UserOwned;
|
||||
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
|
@ -13,6 +15,9 @@ public class TransactionCategory extends AbstractEntity implements UserOwned {
|
|||
|
||||
private String name;
|
||||
|
||||
@Convert(converter = CategorizationRuleBehavior.JpaConverter.class)
|
||||
private CategorizationRuleBehavior ruleBehavior;
|
||||
|
||||
public TransactionCategory() {
|
||||
}
|
||||
|
||||
|
@ -31,4 +36,12 @@ public class TransactionCategory extends AbstractEntity implements UserOwned {
|
|||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public CategorizationRuleBehavior getRuleBehavior() {
|
||||
return ruleBehavior;
|
||||
}
|
||||
|
||||
public void setRuleBehavior(CategorizationRuleBehavior ruleBehavior) {
|
||||
this.ruleBehavior = ruleBehavior;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
// TODO: Use Recursive CTE
|
||||
@Query(
|
||||
value = """
|
||||
WITH RECURSIVE cats AS (
|
||||
SELECT cat.*
|
||||
FROM categories.categorization AS cat
|
||||
WHERE cat.category_id = :categoryId
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.*
|
||||
FROM categories.categorization AS l
|
||||
JOIN cats ON cats.`left` = l.id
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT r.*
|
||||
FROM categories.categorization AS r
|
||||
JOIN cats ON cats.`right` = r.id
|
||||
)
|
||||
SELECT * FROM cats;
|
||||
WITH RECURSIVE
|
||||
childCats AS (
|
||||
SELECT root.*
|
||||
FROM categories.categorization AS root
|
||||
WHERE root.category_id = :categoryId
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT c.*
|
||||
FROM categories.categorization AS c, childCats
|
||||
WHERE childCats.right_categorization_id = c.id OR childCats.left_categorization_id = c.id
|
||||
)
|
||||
SELECT DISTINCT * FROM childCats;
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence;
|
|||
|
||||
import dev.mvvasilev.finances.dtos.CategoryDTO;
|
||||
import dev.mvvasilev.finances.entity.TransactionCategory;
|
||||
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
@ -15,7 +16,16 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
|
|||
@Query(value = "SELECT * FROM categories.transaction_category WHERE user_id = :userId", nativeQuery = true)
|
||||
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
|
||||
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.web.CrudResponseDTO;
|
||||
import dev.mvvasilev.finances.dtos.*;
|
||||
import dev.mvvasilev.finances.entity.Categorization;
|
||||
import dev.mvvasilev.finances.entity.ProcessedTransaction;
|
||||
import dev.mvvasilev.finances.entity.ProcessedTransactionCategory;
|
||||
import dev.mvvasilev.finances.entity.TransactionCategory;
|
||||
import dev.mvvasilev.finances.entity.*;
|
||||
import dev.mvvasilev.finances.persistence.CategorizationRepository;
|
||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionCategoryRepository;
|
||||
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
|
||||
|
@ -18,10 +15,7 @@ import org.springframework.stereotype.Service;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
@ -64,14 +58,15 @@ public class CategoryService {
|
|||
public Collection<CategoryDTO> listForUser(int userId) {
|
||||
return transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId)
|
||||
.stream()
|
||||
.map(entity -> new CategoryDTO(entity.getId(), entity.getName()))
|
||||
.map(entity -> new CategoryDTO(entity.getId(), entity.getName(), entity.getRuleBehavior()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int update(Long categoryId, UpdateCategoryDTO dto) {
|
||||
return transactionCategoryRepository.updateTransactionCategoryName(
|
||||
categoryId,
|
||||
dto.name()
|
||||
dto.name(),
|
||||
dto.ruleBehavior()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -81,28 +76,53 @@ public class CategoryService {
|
|||
}
|
||||
|
||||
public void categorizeForUser(int userId) {
|
||||
final var categories = transactionCategoryRepository.fetchTransactionCategoriesWithUserId(userId);
|
||||
final var categorizations = categorizationRepository.fetchForUser(userId);
|
||||
final var transactions = processedTransactionRepository.fetchForUser(userId);
|
||||
|
||||
// Run all the categorization rules async
|
||||
// Run each category's rules for all transactions in parallel to eachother
|
||||
final var futures = categorizations.stream()
|
||||
.map(c -> CompletableFuture.supplyAsync(() ->
|
||||
transactions.stream()
|
||||
.map((transaction) -> categorizeTransaction(categorizations, c, transaction))
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.toList())
|
||||
)
|
||||
.collect(Collectors.groupingBy(Categorization::getCategoryId, HashMap::new, Collectors.toList()))
|
||||
.entrySet()
|
||||
.stream()
|
||||
.map(entry -> CompletableFuture.supplyAsync(() -> {
|
||||
final var categoryId = entry.getKey();
|
||||
final var rules = entry.getValue();
|
||||
|
||||
final var category = categories.stream().filter(c -> c.getId() == categoryId).findFirst();
|
||||
|
||||
if (category.isEmpty()) {
|
||||
throw new CommonFinancesException("Orphaned categorization, invalid categoryId");
|
||||
}
|
||||
|
||||
return transactions.stream()
|
||||
.map(transaction -> {
|
||||
final var matches = switch (category.get().getRuleBehavior()) {
|
||||
case ANY -> rules.stream().anyMatch(r -> matchesRule(categorizations, r, transaction));
|
||||
case ALL -> rules.stream().allMatch(r -> matchesRule(categorizations, r, transaction));
|
||||
case NONE -> rules.stream().noneMatch(r -> matchesRule(categorizations, r, transaction));
|
||||
};
|
||||
|
||||
if (matches) {
|
||||
return Optional.of(new ProcessedTransactionCategory(transaction.getId(), categoryId));
|
||||
} else {
|
||||
return Optional.<ProcessedTransactionCategory>empty();
|
||||
}
|
||||
})
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.toList();
|
||||
}))
|
||||
.toArray(length -> (CompletableFuture<List<ProcessedTransactionCategory>>[]) new CompletableFuture[length]);
|
||||
|
||||
// Run them all in parallel
|
||||
final var categories = CompletableFuture.allOf(futures).thenApply((v) ->
|
||||
final var ptcs = CompletableFuture.allOf(futures).thenApply((v) ->
|
||||
Arrays.stream(futures)
|
||||
.flatMap(future -> future.join().stream())
|
||||
.toList()
|
||||
).join();
|
||||
|
||||
processedTransactionCategoryRepository.saveAllAndFlush(categories);
|
||||
processedTransactionCategoryRepository.saveAllAndFlush(ptcs);
|
||||
}
|
||||
|
||||
private Optional<ProcessedTransactionCategory> categorizeTransaction(final Collection<Categorization> allCategorizations, Categorization categorization, ProcessedTransaction processedTransaction) {
|
||||
|
@ -247,45 +267,79 @@ public class CategoryService {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
final var newCategorizations = dtos.stream()
|
||||
.map(dto -> saveCategorizationRule(categoryId, dto))
|
||||
.toList();
|
||||
|
||||
return categorizationRepository.saveAllAndFlush(newCategorizations).stream()
|
||||
.map(AbstractEntity::getId)
|
||||
return dtos.stream()
|
||||
.map(dto -> saveCategorizationRule(categoryId, userId, dto).getId())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Categorization saveCategorizationRule(Long categoryId, CreateCategorizationDTO dto) {
|
||||
private Categorization saveCategorizationRule(Long categoryId, Integer userId, CreateCategorizationDTO dto) {
|
||||
// TODO: Avoid recursion
|
||||
|
||||
final var categorization = new Categorization();
|
||||
|
||||
categorization.setCategorizationRule(dto.rule());
|
||||
categorization.setCategoryId(null);
|
||||
categorization.setStringValue(dto.stringValue());
|
||||
categorization.setNumericGreaterThan(dto.numericGreaterThan());
|
||||
categorization.setNumericLessThan(dto.numericLessThan());
|
||||
categorization.setNumericValue(dto.numericValue());
|
||||
categorization.setTimestampGreaterThan(dto.timestampGreaterThan());
|
||||
categorization.setTimestampLessThan(dto.timestampLessThan());
|
||||
categorization.setBooleanValue(dto.booleanValue());
|
||||
categorization.setUserId(userId);
|
||||
categorization.setRuleBasedOn(dto.ruleBasedOn());
|
||||
categorization.setCategoryId(categoryId);
|
||||
categorization.setStringValue(dto.stringValue().orElse(null));
|
||||
categorization.setNumericGreaterThan(dto.numericGreaterThan().orElse(null));
|
||||
categorization.setNumericLessThan(dto.numericLessThan().orElse(null));
|
||||
categorization.setNumericValue(dto.numericValue().orElse(null));
|
||||
categorization.setTimestampGreaterThan(dto.timestampGreaterThan().orElse(null));
|
||||
categorization.setTimestampLessThan(dto.timestampLessThan().orElse(null));
|
||||
categorization.setBooleanValue(dto.booleanValue().orElse(null));
|
||||
|
||||
// Only root rules have category id set, to differentiate them from non-roots
|
||||
// TODO: This smells bad. Add an isRoot property instead?
|
||||
if (dto.left() != null) {
|
||||
final var leftCat = saveCategorizationRule(null, dto.left());
|
||||
final var leftCat = saveCategorizationRule(null, userId, dto.left());
|
||||
categorization.setLeftCategorizationId(leftCat.getId());
|
||||
}
|
||||
|
||||
if (dto.right() != null) {
|
||||
final var rightCat = saveCategorizationRule(null, dto.right());
|
||||
final var rightCat = saveCategorizationRule(null, userId, dto.right());
|
||||
categorization.setRightCategorizationId(rightCat.getId());
|
||||
}
|
||||
|
||||
|
|
|
@ -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/material": "^5.15.0",
|
||||
"@mui/x-data-grid": "^6.18.6",
|
||||
"@mui/x-date-pickers": "^6.18.6",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "^16.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-material-ui-carousel": "^3.4.2",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9"
|
||||
},
|
||||
|
@ -1372,6 +1375,71 @@
|
|||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers": {
|
||||
"version": "6.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.18.6.tgz",
|
||||
"integrity": "sha512-pqOrGPUDVY/1xXrM1hofqwgquno/SB9aG9CVS1m2Rs8hKF1VWRC+jYlEa1Qk08xKmvkia5g7NsdV/BBb+tHUZw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2",
|
||||
"@mui/base": "^5.0.0-beta.22",
|
||||
"@mui/utils": "^5.14.16",
|
||||
"@types/react-transition-group": "^4.4.8",
|
||||
"clsx": "^2.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/material": "^5.8.6",
|
||||
"@mui/system": "^5.8.0",
|
||||
"date-fns": "^2.25.0",
|
||||
"date-fns-jalali": "^2.13.0-0",
|
||||
"dayjs": "^1.10.7",
|
||||
"luxon": "^3.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-hijri": "^2.1.2",
|
||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns-jalali": {
|
||||
"optional": true
|
||||
},
|
||||
"dayjs": {
|
||||
"optional": true
|
||||
},
|
||||
"luxon": {
|
||||
"optional": true
|
||||
},
|
||||
"moment": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-hijri": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-jalaali": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
@ -2089,6 +2157,11 @@
|
|||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
|
||||
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
@ -4672,7 +4745,6 @@
|
|||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
|
|
|
@ -16,12 +16,15 @@
|
|||
"@mui/icons-material": "^5.15.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@mui/x-data-grid": "^6.18.6",
|
||||
"@mui/x-date-pickers": "^6.18.6",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "^16.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-material-ui-carousel": "^3.4.2",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9"
|
||||
},
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import { Routes, Route } from 'react-router-dom';
|
||||
import HomePage from "@/app/pages/HomePage"
|
||||
import RootLayout from '@/app/Layout';
|
||||
import StatementsPage from './app/pages/StatementsPage.jsx';
|
||||
import StatisticsPage from "@/app/pages/StatisticsPage.jsx"
|
||||
import StatementsPage from '@/app/pages/StatementsPage.jsx';
|
||||
import TransactionsPage from "@/app/pages/TransactionsPage.jsx";
|
||||
import CategoriesPage from "@/app/pages/CategoriesPage.jsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<RootLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/" element={<StatisticsPage />} />
|
||||
<Route path="/statements" element={<StatementsPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
</Routes>
|
||||
</RootLayout>
|
||||
</>
|
||||
|
|
|
@ -18,6 +18,11 @@ import {Logout as LogoutIcon} from '@mui/icons-material';
|
|||
import {Login as LoginIcon} from "@mui/icons-material";
|
||||
import {Toaster} from 'react-hot-toast';
|
||||
import theme from '../components/ThemeRegistry/theme';
|
||||
import utils from "@/utils.js";
|
||||
import {CircularProgress} from "@mui/material";
|
||||
import {useEffect, useState} from "react";
|
||||
import {LocalizationProvider} from "@mui/x-date-pickers";
|
||||
import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs";
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
|
@ -49,10 +54,38 @@ function isLoggedIn() {
|
|||
}
|
||||
|
||||
export default function RootLayout({children}) {
|
||||
|
||||
const [spinner, showSpinner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("onSpinnerStatusChange", () => {
|
||||
showSpinner(utils.isSpinnerShown());
|
||||
})
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline/>
|
||||
|
||||
{
|
||||
<Backdrop
|
||||
sx={{
|
||||
color: '#fff',
|
||||
zIndex: 2147483647
|
||||
}}
|
||||
open={spinner}
|
||||
>
|
||||
<CircularProgress
|
||||
sx={{
|
||||
position: "absolute",
|
||||
zIndex: 2147483647,
|
||||
top: "50%",
|
||||
left: "50%"
|
||||
}}
|
||||
/>
|
||||
</Backdrop>
|
||||
}
|
||||
|
||||
<Drawer
|
||||
sx={{
|
||||
width: DRAWER_WIDTH,
|
||||
|
@ -109,20 +142,22 @@ export default function RootLayout({children}) {
|
|||
</form>}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
left: `${DRAWER_WIDTH}px`,
|
||||
right: 0,
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
left: `${DRAWER_WIDTH}px`,
|
||||
right: 0,
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
|
||||
<Toaster
|
||||
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 Carousel from "react-material-ui-carousel";
|
||||
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
|
||||
import Divider from "@mui/material/Divider";
|
||||
|
||||
|
||||
export default function StatementsPage() {
|
||||
|
@ -22,9 +23,15 @@ export default function StatementsPage() {
|
|||
}, []);
|
||||
|
||||
function fetchStatements() {
|
||||
utils.showSpinner();
|
||||
|
||||
utils.performRequest("/api/statements")
|
||||
.then(resp => resp.json())
|
||||
.then(({ result }) => setStatements(result));
|
||||
.then(({ result }) => {
|
||||
setStatements(result);
|
||||
|
||||
utils.hideSpinner();
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadStatement({ target }) {
|
||||
|
@ -33,11 +40,13 @@ export default function StatementsPage() {
|
|||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
utils.showSpinner();
|
||||
|
||||
await toast.promise(
|
||||
utils.performRequest("/api/statements/uploadSheet", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}),
|
||||
method: "POST",
|
||||
body: formData
|
||||
}),
|
||||
{
|
||||
loading: "Uploading...",
|
||||
success: () => {
|
||||
|
@ -45,7 +54,11 @@ export default function StatementsPage() {
|
|||
|
||||
return "Upload successful!";
|
||||
},
|
||||
error: (err) => `Uh oh, something went wrong: ${err}`
|
||||
error: (err) => {
|
||||
utils.hideSpinner();
|
||||
|
||||
return `Uh oh, something went wrong: ${err}`;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -138,6 +151,10 @@ export default function StatementsPage() {
|
|||
}
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12}>
|
||||
{
|
||||
mappingStatementId !== -1 &&
|
||||
|
|
|
@ -3,7 +3,7 @@ import Grid from '@mui/material/Unstable_Grid2';
|
|||
import MediaCard from '@/components/MediaCard';
|
||||
import { Stack } from '@mui/material';
|
||||
|
||||
export default function HomePage() {
|
||||
export default function StatisticsPage() {
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
|
@ -3,6 +3,7 @@ import {Stack} from "@mui/material";
|
|||
import {useEffect, useState} from "react";
|
||||
import {DataGrid} from "@mui/x-data-grid";
|
||||
import utils from "@/utils.js";
|
||||
import {ArrowDownward, ArrowUpward, PriceChange} from "@mui/icons-material";
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
|
@ -12,6 +13,19 @@ const COLUMNS = [
|
|||
width: 150,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
renderCell: (params) => {
|
||||
return params.value ? (
|
||||
<>
|
||||
<PriceChange style={{ color: '#4d4' }} />
|
||||
<ArrowUpward style={{ color: '#4d4' }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PriceChange style={{ color: '#d44' }} />
|
||||
<ArrowDownward style={{ color: '#d44' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
|
@ -21,7 +35,7 @@ const COLUMNS = [
|
|||
flex: true,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
valueFormatter: val => `${val.value} лв.`
|
||||
valueFormatter: val => `${(val.value).toLocaleString(undefined, { minimumFractionDigits: 2 })} лв.`
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
|
@ -40,7 +54,7 @@ const COLUMNS = [
|
|||
flex: true,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
valueFormatter: val => new Date(val.value).toLocaleString('en-UK')
|
||||
valueFormatter: val => new Date(val.value).toLocaleString("bg-BG")
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -48,7 +62,7 @@ export default function TransactionsPage() {
|
|||
|
||||
const [pageOptions, setPageOptions] = useState({
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
const [sortOptions, setSortOptions] = useState([
|
||||
|
@ -61,6 +75,8 @@ export default function TransactionsPage() {
|
|||
const [transactions, setTransactions] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
utils.showSpinner();
|
||||
|
||||
// Multi-sorting requires the MUI data grid pro license :)
|
||||
let sortBy = sortOptions.map((sort) => `&sort=${sort.field},${sort.sort}`).join("")
|
||||
|
||||
|
@ -68,17 +84,34 @@ export default function TransactionsPage() {
|
|||
|
||||
utils.performRequest(`/api/processed-transactions?page=${pageOptions.page}&size=${pageOptions.pageSize}${sortBy}`)
|
||||
.then(resp => resp.json())
|
||||
.then(({result}) => setTransactions(result));
|
||||
.then(({result}) => {
|
||||
setTransactions(result);
|
||||
utils.hideSpinner();
|
||||
});
|
||||
}, [pageOptions, sortOptions]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Grid container columnSpacing={1}>
|
||||
<Grid xs={12} lg={12}>
|
||||
<Stack
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
columnSpacing={1}
|
||||
>
|
||||
<Grid
|
||||
sx={{
|
||||
height: "1200px"
|
||||
}}
|
||||
xs={12}
|
||||
lg={12}
|
||||
>
|
||||
<DataGrid
|
||||
sx={{
|
||||
overflowY: "scroll"
|
||||
}}
|
||||
columns={COLUMNS}
|
||||
rows={transactions.content ?? []}
|
||||
rowCount={transactions.totalElements ?? 0}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
paginationMode={"server"}
|
||||
sortingMode={"server"}
|
||||
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 />}>
|
||||
Map
|
||||
</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
|
||||
</Button>
|
||||
</CardActions>
|
||||
|
|
|
@ -11,6 +11,7 @@ import {useEffect, useState} from "react";
|
|||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import toast from "react-hot-toast";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
const FIELD_TYPES = [
|
||||
"STRING",
|
||||
|
@ -56,6 +57,8 @@ export default function StatementMappingEditor({statementId}) {
|
|||
const [existingMappings, setExistingMappings] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
utils.showSpinner();
|
||||
|
||||
let supportedConversionsPromise = utils.performRequest("/api/statements/supported-conversions")
|
||||
.then(resp => resp.json())
|
||||
.then(({result}) => setSupportedConversions(result));
|
||||
|
@ -73,7 +76,8 @@ export default function StatementMappingEditor({statementId}) {
|
|||
.then(({result}) => setFields(result));
|
||||
|
||||
toast.promise(
|
||||
Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise]),
|
||||
Promise.all([supportedConversionsPromise, valueGroupsPromise, existingMappingsPromise, fieldsPromise])
|
||||
.then(r => utils.hideSpinner()),
|
||||
{
|
||||
loading: "Preparing...",
|
||||
success: "Ready",
|
||||
|
@ -217,7 +221,7 @@ export default function StatementMappingEditor({statementId}) {
|
|||
<Input
|
||||
onChange={(e) => onChangeStringToBooleanTrueValue(e, mapping)}
|
||||
value={mapping.conversion.selected.trueBranchStringValue}
|
||||
/> = true, else false
|
||||
/><Typography display={"inline"}>= true, else false</Typography>
|
||||
</Box>
|
||||
)
|
||||
default: return (<p>Unsupported</p>)
|
||||
|
@ -260,16 +264,24 @@ export default function StatementMappingEditor({statementId}) {
|
|||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: "300px"}} align="left" >Statement Column</TableCell>
|
||||
<TableCell align="left">Conversion</TableCell>
|
||||
<TableCell style={{width: "300px"}} align="left">Transaction Field</TableCell>
|
||||
<TableCell style={{width: "300px"}} align="left" >
|
||||
<Typography fontSize={"1.25em"}>Statement Column</Typography>
|
||||
</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>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{
|
||||
mappings.map(m => (
|
||||
<TableRow key={m.valueGroup.id}>
|
||||
<TableCell>{m.valueGroup.name}</TableCell>
|
||||
<TableCell>
|
||||
<Typography>{m.valueGroup.name}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Grid columnSpacing={1} container>
|
||||
<Grid xs={2} lg={2}>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { v4 } from 'uuid';
|
||||
|
||||
let utils = {
|
||||
performRequest: async (url, options) => {
|
||||
return await fetch(url, options).then(resp => {
|
||||
if (resp.status === 401) {
|
||||
window.location.replace("https://localhost:8080/oauth2/authorization/authentik")
|
||||
window.location.replace(`${window.location.origin}/oauth2/authorization/authentik`)
|
||||
|
||||
throw "Unauthorized, please login.";
|
||||
}
|
||||
|
@ -14,8 +16,23 @@ let utils = {
|
|||
return resp;
|
||||
});
|
||||
},
|
||||
isSpinnerShown: () => {
|
||||
return localStorage.getItem("SpinnerShowing") === "true";
|
||||
},
|
||||
showSpinner: () => {
|
||||
localStorage.setItem("SpinnerShowing", "true");
|
||||
window.dispatchEvent(new Event("onSpinnerStatusChange"));
|
||||
},
|
||||
hideSpinner: () => {
|
||||
localStorage.removeItem("SpinnerShowing");
|
||||
window.dispatchEvent(new Event("onSpinnerStatusChange"));
|
||||
},
|
||||
toPascalCase: (s) => {
|
||||
return s.replace(/(\w)(\w*)/g, (g0,g1,g2) => g1.toUpperCase() + g2.toLowerCase());
|
||||
},
|
||||
generateUUID: () => v4(),
|
||||
isNumeric: (value) => {
|
||||
return /^-?\d+(\.\d+)?$/.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue