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

View file

@ -7,7 +7,9 @@ import java.time.LocalDateTime;
public record CategorizationDTO(
Long id,
CategorizationRule rule,
CategorizationRuleDTO rule,
ProcessedTransactionFieldDTO ruleBasedOn,
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;
import dev.mvvasilev.finances.enums.CategorizationRuleBehavior;
public record CategoryDTO(
Long id,
String name
String name,
CategorizationRuleBehavior ruleBehavior
) {
}

View file

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

View file

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

View file

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

View file

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

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);
// 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
)

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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