diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 2e17bc7..c405e57 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,7 +1,7 @@ - + postgresql true org.postgresql.Driver diff --git a/.idea/misc.xml b/.idea/misc.xml index 32d884a..f16dea7 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/Common/build.gradle b/Common/build.gradle index 6f33e3e..4289aaa 100644 --- a/Common/build.gradle +++ b/Common/build.gradle @@ -12,6 +12,7 @@ repositories { dependencies { implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0' implementation 'org.springframework:spring-web:6.1.1' + implementation 'org.springframework.data:spring-data-jpa:3.2.0' testImplementation platform('org.junit:junit-bom:5.9.1') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java b/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java index 40353c2..b4a5eeb 100644 --- a/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java +++ b/Common/src/main/java/dev/mvvasilev/common/controller/AbstractRestController.java @@ -3,7 +3,9 @@ package dev.mvvasilev.common.controller; import dev.mvvasilev.common.web.APIErrorDTO; import dev.mvvasilev.common.web.APIResponseDTO; import dev.mvvasilev.common.web.CrudResponseDTO; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import java.util.Collection; @@ -24,11 +26,11 @@ public abstract class AbstractRestController { return withStatus(HttpStatus.OK, body); } - protected ResponseEntity> emptySuccess() { + protected ResponseEntity> emptySuccess() { return withStatus(HttpStatus.OK, null); } - protected ResponseEntity> created(Long id) { + protected ResponseEntity> created(Long id) { return withStatus(HttpStatus.CREATED, new CrudResponseDTO(id, null)); } @@ -49,4 +51,10 @@ public abstract class AbstractRestController { return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows)); } + protected ResponseEntity> file(byte[] fileBytes, MediaType mediaType) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(mediaType); + + return new ResponseEntity<>(new APIResponseDTO<>(fileBytes, null, HttpStatus.OK.value(), HttpStatus.OK.getReasonPhrase()), headers, HttpStatus.OK); + } } diff --git a/Common/src/main/java/dev/mvvasilev/common/data/UserOwnedEntityRepository.java b/Common/src/main/java/dev/mvvasilev/common/data/UserOwnedEntityRepository.java new file mode 100644 index 0000000..8775e30 --- /dev/null +++ b/Common/src/main/java/dev/mvvasilev/common/data/UserOwnedEntityRepository.java @@ -0,0 +1,15 @@ +package dev.mvvasilev.common.data; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; + +public interface UserOwnedEntityRepository extends JpaRepository { + + Page findAllByUserId(int userId, Pageable pageable); + + Collection findAllByUserId(int userId); + +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java index 5ee517a..cee57e8 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/CategoriesController.java @@ -1,5 +1,7 @@ package dev.mvvasilev.finances.controllers; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.mvvasilev.common.controller.AbstractRestController; import dev.mvvasilev.common.web.APIResponseDTO; import dev.mvvasilev.common.web.CrudResponseDTO; @@ -7,11 +9,15 @@ 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.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.nio.charset.Charset; import java.util.Arrays; import java.util.Collection; @@ -21,9 +27,12 @@ public class CategoriesController extends AbstractRestController { final private CategoryService categoryService; + final private ObjectMapper objectMapper; + @Autowired - public CategoriesController(CategoryService categoryService) { + public CategoriesController(CategoryService categoryService, ObjectMapper objectMapper) { this.categoryService = categoryService; + this.objectMapper = objectMapper; } @GetMapping("/rules") @@ -80,14 +89,25 @@ public class CategoriesController extends AbstractRestController { return created(categoryService.createCategorizationRules(categoryId, Integer.parseInt(authentication.getName()), dto)); } -// @DeleteMapping("/{categoryId}/rules/{ruleId}") -// @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))") -// public ResponseEntity> deleteCategorizationRule( -// @PathVariable("categoryId") Long categoryId, -// @PathVariable("ruleId") Long ruleId -// ) { -// return deleted(categoryService.deleteCategorizationRule(ruleId)); -// } + @GetMapping("/export") + public ResponseEntity> exportCategories(Authentication authentication) throws JsonProcessingException { + var json = objectMapper.writeValueAsString(categoryService.exportCategoriesForUser(Integer.parseInt(authentication.getName()))); + + return file(json.getBytes(Charset.defaultCharset()), MediaType.APPLICATION_JSON); + } + + @PostMapping("/import") + public ResponseEntity>> importCategories( + @RequestParam("file") MultipartFile file, + @RequestParam("deleteExisting") Boolean deleteExisting, + Authentication authentication + ) throws IOException { + return created(categoryService.importCategoriesForUser( + objectMapper.readValue(file.getBytes(), ImportExportCategoriesDTO.class), + deleteExisting, + Integer.parseInt(authentication.getName()) + )); + } @PostMapping("/categorize") public ResponseEntity> categorizeTransactions(Authentication authentication) { diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/StatisticsController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/StatisticsController.java index b19f009..2d0019b 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/StatisticsController.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/StatisticsController.java @@ -29,8 +29,13 @@ public class StatisticsController extends AbstractRestController { this.statisticsService = statisticsService; } + @GetMapping("/timePeriods") + public ResponseEntity> fetchTimePeriods() { + return ok(TimePeriod.values()); + } + @GetMapping("/totalSpendingByCategory") - @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))") + @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))") public ResponseEntity> fetchSpendingByCategory( Long[] categoryId, @RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from, @@ -40,7 +45,7 @@ public class StatisticsController extends AbstractRestController { } @GetMapping("/spendingOverTimeByCategory") - @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))") + @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))") public ResponseEntity> fetchSpendingOverTimeByCategory( Long[] categoryId, @RequestParam(defaultValue = "DAILY") TimePeriod period, diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/WidgetsController.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/WidgetsController.java index 710a278..97c8c67 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/WidgetsController.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/controllers/WidgetsController.java @@ -3,18 +3,52 @@ package dev.mvvasilev.finances.controllers; import dev.mvvasilev.common.controller.AbstractRestController; import dev.mvvasilev.common.web.APIResponseDTO; import dev.mvvasilev.common.web.CrudResponseDTO; +import dev.mvvasilev.finances.dtos.CreateUpdateWidgetDTO; +import dev.mvvasilev.finances.dtos.WidgetDTO; +import dev.mvvasilev.finances.enums.WidgetType; +import dev.mvvasilev.finances.services.WidgetService; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Collection; @RestController @RequestMapping("/widgets") public class WidgetsController extends AbstractRestController { -// ResponseEntity> create(CreateDTO dto); -// -// ResponseEntity> update(Long id, UpdateDTO dto); -// -// ResponseEntity> delete(Long id); + private final WidgetService widgetService; + + public WidgetsController(WidgetService widgetService) { + this.widgetService = widgetService; + } + + @GetMapping("/types") + public ResponseEntity> fetchWidgetTypes() { + return ok(WidgetType.values()); + } + + @GetMapping + public ResponseEntity>> fetchAllForUser(Authentication authentication) { + return ok(widgetService.fetchAllForUser(Integer.parseInt(authentication.getName()))); + } + + @PostMapping + public ResponseEntity> create(@RequestBody CreateUpdateWidgetDTO dto, Authentication authentication) { + return created(widgetService.createWidget(dto, Integer.parseInt(authentication.getName()))); + } + + @PutMapping("/{widgetId}") + @PreAuthorize("@authService.isOwner(#widgetId, T(dev.mvvasilev.finances.entity.Widget))") + public ResponseEntity> update(@PathVariable Long widgetId, @RequestBody CreateUpdateWidgetDTO dto) { + return updated(widgetService.updateWidget(widgetId, dto)); + } + + @DeleteMapping("/{widgetId}") + @PreAuthorize("@authService.isOwner(#widgetId, T(dev.mvvasilev.finances.entity.Widget))") + public ResponseEntity> delete(@PathVariable Long widgetId) { + return deleted(widgetService.deleteWidget(widgetId)); + } } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java index 2b12418..e5e6d0d 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CategorizationDTO.java @@ -7,6 +7,8 @@ import java.time.LocalDateTime; public record CategorizationDTO( Long id, + Long categoryId, + CategorizationRuleDTO rule, ProcessedTransactionFieldDTO ruleBasedOn, diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateUpdateWidgetDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateUpdateWidgetDTO.java new file mode 100644 index 0000000..f5d6b34 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateUpdateWidgetDTO.java @@ -0,0 +1,26 @@ +package dev.mvvasilev.finances.dtos; + +import dev.mvvasilev.finances.enums.WidgetType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.Length; + +import java.util.List; + +public record CreateUpdateWidgetDTO( + @Length(max = 255, message = "Length of name cannot be more than 255 characters.") + @NotNull(message = "Widget must have name.") + String name, + @Min(value = 0, message = "Horizontal position of widget cannot be less than 0.") + Integer positionX, + @Min(value = 0, message = "Vertical position of widget cannot be less than 0.") + Integer positionY, + @Min(value = 1, message = "Horizontal size of widget cannot be less than 1.") + Integer sizeX, + @Min(value = 1, message = "Vertical size of widget cannot be less than 1.") + Integer sizeY, + @NotNull(message = "Widget must have type.") + WidgetType type, + @NotNull(message = "Widget must have parameters, even if empty.") + List parameters +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateWidgetParameterDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateWidgetParameterDTO.java new file mode 100644 index 0000000..f5c30e2 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/CreateWidgetParameterDTO.java @@ -0,0 +1,11 @@ +package dev.mvvasilev.finances.dtos; + +import java.time.LocalDateTime; + +public record CreateWidgetParameterDTO ( + String name, + String stringValue, + Double numericValue, + LocalDateTime timestampValue, + Boolean booleanValue +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/ImportExportCategoriesDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/ImportExportCategoriesDTO.java new file mode 100644 index 0000000..267bdca --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/ImportExportCategoriesDTO.java @@ -0,0 +1,9 @@ +package dev.mvvasilev.finances.dtos; + +import java.util.Collection; + +public record ImportExportCategoriesDTO( + Collection categories, + + Collection categorizationRules +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetDTO.java new file mode 100644 index 0000000..83e916a --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetDTO.java @@ -0,0 +1,16 @@ +package dev.mvvasilev.finances.dtos; + +import dev.mvvasilev.finances.enums.WidgetType; + +import java.util.List; + +public record WidgetDTO ( + Long id, + String name, + int positionX, + int positionY, + int sizeX, + int sizeY, + WidgetType type, + List parameters +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetParameterDTO.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetParameterDTO.java new file mode 100644 index 0000000..872e1b8 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/dtos/WidgetParameterDTO.java @@ -0,0 +1,12 @@ +package dev.mvvasilev.finances.dtos; + +import java.time.LocalDateTime; + +public record WidgetParameterDTO ( + Long id, + String name, + String stringValue, + Double numericValue, + LocalDateTime timestampValue, + Boolean booleanValue +) {} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Widget.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Widget.java index aa09e2b..d2b70a1 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Widget.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/entity/Widget.java @@ -1,6 +1,7 @@ package dev.mvvasilev.finances.entity; import dev.mvvasilev.common.data.AbstractEntity; +import dev.mvvasilev.common.data.UserOwned; import dev.mvvasilev.finances.enums.WidgetType; import jakarta.persistence.Convert; import jakarta.persistence.Converter; @@ -9,21 +10,23 @@ import jakarta.persistence.Table; @Entity @Table(schema = "widgets") -public class Widget extends AbstractEntity { +public class Widget extends AbstractEntity implements UserOwned { @Convert(converter = WidgetType.JpaConverter.class) private WidgetType type; - private int positionX; + private Integer positionX; - private int positionY; + private Integer positionY; - private int sizeX; + private Integer sizeX; - private int sizeY; + private Integer sizeY; private String name; + private Integer userId; + public Widget() { } @@ -35,35 +38,35 @@ public class Widget extends AbstractEntity { this.type = type; } - public int getPositionX() { + public Integer getPositionX() { return positionX; } - public void setPositionX(int positionX) { + public void setPositionX(Integer positionX) { this.positionX = positionX; } - public int getPositionY() { + public Integer getPositionY() { return positionY; } - public void setPositionY(int positionY) { + public void setPositionY(Integer positionY) { this.positionY = positionY; } - public int getSizeX() { + public Integer getSizeX() { return sizeX; } - public void setSizeX(int sizeX) { + public void setSizeX(Integer sizeX) { this.sizeX = sizeX; } - public int getSizeY() { + public Integer getSizeY() { return sizeY; } - public void setSizeY(int sizeY) { + public void setSizeY(Integer sizeY) { this.sizeY = sizeY; } @@ -74,4 +77,13 @@ public class Widget extends AbstractEntity { public void setName(String name) { this.name = name; } + + @Override + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/StatisticsRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/StatisticsRepository.java index b8d0a14..3107943 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/StatisticsRepository.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/StatisticsRepository.java @@ -35,7 +35,7 @@ public class StatisticsRepository { JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id WHERE pt.is_inflow = FALSE - AND ptc.category_id IN (?1) + AND ptc.category_id = any(?1) AND (pt.timestamp BETWEEN ?2 AND ?3) GROUP BY ptc.category_id ORDER BY total_spending DESC; diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java index 24818a1..bffa2f2 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/TransactionCategoryRepository.java @@ -38,4 +38,6 @@ public interface TransactionCategoryRepository extends JpaRepository fetchCategoriesForTransaction(@Param("transactionId") Long transactionId); + + void deleteAllByUserId(@Param("userId") Integer userId); } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetParameterRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetParameterRepository.java new file mode 100644 index 0000000..73b6442 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetParameterRepository.java @@ -0,0 +1,15 @@ +package dev.mvvasilev.finances.persistence; + +import dev.mvvasilev.finances.entity.WidgetParameter; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; + +@Repository +public interface WidgetParameterRepository extends JpaRepository { + + Collection findAllByWidgetIdIn(Collection widgetIds); + +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetRepository.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetRepository.java new file mode 100644 index 0000000..f939bc9 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/persistence/WidgetRepository.java @@ -0,0 +1,10 @@ +package dev.mvvasilev.finances.persistence; + +import dev.mvvasilev.common.data.UserOwnedEntityRepository; +import dev.mvvasilev.finances.entity.Widget; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WidgetRepository extends UserOwnedEntityRepository { +} diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java index d7e0fca..49eb4e9 100644 --- a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/CategoryService.java @@ -281,6 +281,7 @@ public class CategoryService { private CategorizationDTO mapCategorization(final Collection all, Categorization categorization) { return new CategorizationDTO( categorization.getId(), + categorization.getCategoryId(), new CategorizationRuleDTO( categorization.getCategorizationRule(), categorization.getCategorizationRule().applicableForType() @@ -350,4 +351,56 @@ public class CategoryService { return categorizationRepository.saveAndFlush(categorization); } + + public ImportExportCategoriesDTO exportCategoriesForUser(int userId) { + final var categories = listForUser(userId); + + return new ImportExportCategoriesDTO( + categories, + categories.stream().map(c -> fetchCategorizationRules(c.id())).flatMap(Collection::stream).toList() + ); + } + + public Collection importCategoriesForUser(ImportExportCategoriesDTO dto, boolean deleteExisting, int userId) { + if (deleteExisting) { + transactionCategoryRepository.deleteAllByUserId(userId); + } + + return dto.categories().stream().map(c -> { + var category = new TransactionCategory(); + + category.setUserId(userId); + category.setName(c.name()); + category.setRuleBehavior(c.ruleBehavior()); + + category = transactionCategoryRepository.saveAndFlush(category); + + createCategorizationRules( + category.getId(), + category.getUserId(), + dto.categorizationRules().stream() + .filter(cr -> Objects.equals(cr.categoryId(), c.id())) + .map(this::mapCategorizationForImport) + .toList() + ); + + return category.getId(); + }).toList(); + } + + private CreateCategorizationDTO mapCategorizationForImport(CategorizationDTO cr) { + return new CreateCategorizationDTO ( + cr.rule().rule(), + cr.ruleBasedOn().field(), + Optional.ofNullable(cr.stringValue()), + Optional.ofNullable(cr.numericGreaterThan()), + Optional.ofNullable(cr.numericLessThan()), + Optional.ofNullable(cr.numericValue()), + Optional.ofNullable(cr.timestampGreaterThan()), + Optional.ofNullable(cr.timestampLessThan()), + Optional.ofNullable(cr.booleanValue()), + cr.left() != null ? mapCategorizationForImport(cr.left()) : null, + cr.right() != null ? mapCategorizationForImport(cr.right()) : null + ); + } } diff --git a/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WidgetService.java b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WidgetService.java new file mode 100644 index 0000000..e8f0897 --- /dev/null +++ b/PersonalFinancesService/src/main/java/dev/mvvasilev/finances/services/WidgetService.java @@ -0,0 +1,124 @@ +package dev.mvvasilev.finances.services; + +import dev.mvvasilev.common.exceptions.CommonFinancesException; +import dev.mvvasilev.finances.dtos.CreateUpdateWidgetDTO; +import dev.mvvasilev.finances.dtos.CreateWidgetParameterDTO; +import dev.mvvasilev.finances.dtos.WidgetDTO; +import dev.mvvasilev.finances.dtos.WidgetParameterDTO; +import dev.mvvasilev.finances.entity.Widget; +import dev.mvvasilev.finances.entity.WidgetParameter; +import dev.mvvasilev.finances.persistence.WidgetParameterRepository; +import dev.mvvasilev.finances.persistence.WidgetRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +public class WidgetService { + + private final WidgetRepository widgetRepository; + + private final WidgetParameterRepository widgetParameterRepository; + + public WidgetService(WidgetRepository widgetRepository, WidgetParameterRepository widgetParameterRepository) { + this.widgetRepository = widgetRepository; + this.widgetParameterRepository = widgetParameterRepository; + } + + public long createWidget(CreateUpdateWidgetDTO dto, int userId) { + final var savedWidget = widgetRepository.saveAndFlush(mapWidget(new Widget(), userId, dto)); + + final var params = dto.parameters() + .stream() + .map(p -> mapWidgetParameter(new WidgetParameter(), savedWidget.getId(), p)) + .toList(); + + widgetParameterRepository.saveAllAndFlush(params); + + return savedWidget.getId(); + } + + public int updateWidget(Long id, CreateUpdateWidgetDTO dto) { + var widget = widgetRepository.findById(id); + + if (widget.isEmpty()) { + throw new CommonFinancesException("No widget with id %d exists.", id); + } + + widgetRepository.saveAndFlush(mapWidget(widget.get(), widget.get().getUserId(), dto)); + + return 1; // TODO: fetch rows affected from database + } + + public int deleteWidget(Long id) { + widgetRepository.deleteById(id); + + return 1; // TODO: fetch rows affected from database + } + + private Widget mapWidget(Widget widget, int userId, CreateUpdateWidgetDTO dto) { + widget.setName(dto.name()); + widget.setType(dto.type()); + widget.setUserId(userId); + widget.setPositionX(dto.positionX()); + widget.setPositionY(dto.positionY()); + widget.setSizeX(dto.sizeX()); + widget.setSizeY(dto.sizeY()); + + return widget; + } + + private WidgetDTO mapWidgetDTO(Widget widget, List params) { + return new WidgetDTO( + widget.getId(), + widget.getName(), + widget.getPositionX(), + widget.getPositionY(), + widget.getSizeX(), + widget.getSizeY(), + widget.getType(), + params + ); + } + + private WidgetParameter mapWidgetParameter(WidgetParameter widgetParameter, Long widgetId, CreateWidgetParameterDTO dto) { + widgetParameter.setWidgetId(widgetId); + widgetParameter.setName(dto.name()); + widgetParameter.setBooleanValue(dto.booleanValue()); + widgetParameter.setNumericValue(dto.numericValue()); + widgetParameter.setTimestampValue(dto.timestampValue()); + widgetParameter.setStringValue(dto.stringValue()); + + return widgetParameter; + } + + private WidgetParameterDTO mapWidgetParameterDTO(WidgetParameter widgetParameter) { + return new WidgetParameterDTO( + widgetParameter.getWidgetId(), + widgetParameter.getName(), + widgetParameter.getStringValue(), + widgetParameter.getNumericValue(), + widgetParameter.getTimestampValue(), + widgetParameter.getBooleanValue() + ); + } + + public Collection fetchAllForUser(int userId) { + final var widgets = widgetRepository.findAllByUserId(userId); + final var widgetParams = widgetParameterRepository.findAllByWidgetIdIn(widgets.stream().map(Widget::getId).toList()); + + return widgets.stream().map(w -> { + final var paramDTOs = widgetParams.stream() + .filter(wp -> wp.getWidgetId() == w.getId()) + .map(this::mapWidgetParameterDTO) + .toList(); + + return mapWidgetDTO(w, paramDTOs); + }) + .toList(); + } +} diff --git a/PersonalFinancesService/src/main/resources/application.properties b/PersonalFinancesService/src/main/resources/application.properties index 7133232..53d5fce 100644 --- a/PersonalFinancesService/src/main/resources/application.properties +++ b/PersonalFinancesService/src/main/resources/application.properties @@ -11,6 +11,9 @@ spring.datasource.username=${DATASOURCE_USER} spring.datasource.password=${DATASOURCE_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.properties.hibernate.jdbc.batch_size=10 +spring.jpa.properties.hibernate.order_inserts=true + spring.jpa.generate-ddl=false spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=validate diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.17__AddSpendingOverTimeFunction.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.17__AddSpendingOverTimeFunction.sql index ac53fc4..5064c3d 100644 --- a/PersonalFinancesService/src/main/resources/db/migration/V1.17__AddSpendingOverTimeFunction.sql +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.17__AddSpendingOverTimeFunction.sql @@ -1,3 +1,5 @@ +CREATE SCHEMA IF NOT EXISTS statistics; + CREATE OR REPLACE FUNCTION statistics.spending_over_time( category_ids BIGINT[], time_period TEXT, diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.18__AddWidget.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.18__AddWidget.sql index 0b5ee76..d0d7277 100644 --- a/PersonalFinancesService/src/main/resources/db/migration/V1.18__AddWidget.sql +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.18__AddWidget.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS widgets.widget ( sizeX INTEGER, sizeY INTEGER, name VARCHAR(255), + user_id INTEGER, time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT PK_widget PRIMARY KEY (id) diff --git a/PersonalFinancesService/src/main/resources/db/migration/V1.19__AddWidgetParameter.sql b/PersonalFinancesService/src/main/resources/db/migration/V1.19__AddWidgetParameter.sql index 88336c0..71ceed5 100644 --- a/PersonalFinancesService/src/main/resources/db/migration/V1.19__AddWidgetParameter.sql +++ b/PersonalFinancesService/src/main/resources/db/migration/V1.19__AddWidgetParameter.sql @@ -9,5 +9,5 @@ CREATE TABLE IF NOT EXISTS widgets.widget_parameter ( time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT PK_widget_parameter PRIMARY KEY (id), - CONSTRAINT FK_widget_parameter_widget FOREIGN KEY (widget_id) REFERENCES widgets.widget(id) + CONSTRAINT FK_widget_parameter_widget FOREIGN KEY (widget_id) REFERENCES widgets.widget(id) ON DELETE CASCADE ); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 303d5fe..7b6dcf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,10 +16,13 @@ "@mui/x-data-grid": "^6.18.6", "@mui/x-date-pickers": "^6.18.6", "@mui/x-tree-view": "^6.17.0", + "chart.js": "^4.4.1", "dayjs": "^1.11.10", "dotenv": "^16.3.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hot-toast": "^2.4.1", "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.21.0", @@ -1096,6 +1099,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@mui/base": { "version": "5.0.0-beta.27", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.27.tgz", @@ -2111,6 +2119,17 @@ "node": ">=4" } }, + "node_modules/chart.js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz", + "integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, "node_modules/clsx": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", @@ -2769,6 +2788,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4094,6 +4118,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4106,6 +4139,44 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-grid-layout": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", + "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hot-toast": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", @@ -4157,6 +4228,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.0.tgz", @@ -4249,6 +4332,11 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index d5b22a8..e2feb32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,10 +18,13 @@ "@mui/x-data-grid": "^6.18.6", "@mui/x-date-pickers": "^6.18.6", "@mui/x-tree-view": "^6.17.0", + "chart.js": "^4.4.1", "dayjs": "^1.11.10", "dotenv": "^16.3.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hot-toast": "^2.4.1", "react-material-ui-carousel": "^3.4.2", "react-router-dom": "^6.21.0", diff --git a/frontend/src/app/Layout.jsx b/frontend/src/app/Layout.jsx index 2eb6bc6..6a1a6b7 100644 --- a/frontend/src/app/Layout.jsx +++ b/frontend/src/app/Layout.jsx @@ -10,6 +10,7 @@ import ListItem from '@mui/material/ListItem'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; +import {Analytics as AnalyticsIcon} from '@mui/icons-material'; import {Home as HomeIcon} from '@mui/icons-material'; import {ReceiptLong as StatementsIcon} from '@mui/icons-material'; import {Receipt as TransactionsIcon} from '@mui/icons-material'; @@ -27,7 +28,7 @@ import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; const DRAWER_WIDTH = 240; const NAV_LINKS = [ - {text: 'Home', to: '/', icon: HomeIcon}, + {text: 'Statistics', to: '/', icon: AnalyticsIcon}, {text: 'Statements', to: '/statements', icon: StatementsIcon}, {text: 'Categories', to: '/categories', icon: CategoryIcon}, {text: 'Transactions', to: '/transactions', icon: TransactionsIcon}, diff --git a/frontend/src/app/pages/CategoriesPage.jsx b/frontend/src/app/pages/CategoriesPage.jsx index 8844097..a260320 100644 --- a/frontend/src/app/pages/CategoriesPage.jsx +++ b/frontend/src/app/pages/CategoriesPage.jsx @@ -2,7 +2,7 @@ import { Category as CategoryIcon, Add as AddIcon, Close as CloseIcon, - Save as SaveIcon + Save as SaveIcon, Download, Upload } from "@mui/icons-material"; import {useEffect, useState} from "react"; import utils from "@/utils.js"; @@ -11,17 +11,21 @@ import Grid from "@mui/material/Unstable_Grid2"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import { + Checkbox, Dialog, DialogActions, DialogContent, DialogContentText, - DialogTitle, + DialogTitle, FormControlLabel, Modal, TextField } from "@mui/material"; import Box from "@mui/material/Box"; import CategorizationRulesEditor from "@/components/categories/CategorizationRulesEditor.jsx"; import CategoriesBox from "@/components/categories/CategoriesBox.jsx"; +import {PARAMS} from "@/components/widgets/WidgetParameters.js"; +import * as React from "react"; +import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx"; export default function CategoriesPage() { @@ -31,6 +35,8 @@ export default function CategoriesPage() { const [showConfirmDeleteCategoryModal, openConfirmDeleteCategoryModal] = useState(false); const [showApplyRulesConfirmModal, openApplyRulesConfirmModal] = useState(false); const [newCategoryName, setNewCategoryName] = useState(null); + const [showUploadDialog, openUploadDialog] = useState(false); + const [replaceExistingOnUpload, setReplaceExistingOnUpload] = useState(true); useEffect(() => { utils.showSpinner(); @@ -155,6 +161,61 @@ export default function CategoriesPage() { }); } + function downloadCategories() { + toast.promise( + utils.performRequest("/api/categories/export") + .then(resp => resp.json()) + .then(resp => { + const linkSource = `data:application/json;base64,${resp.result}`; + const downloadLink = document.createElement("a"); + const fileName = "categories.json"; + + downloadLink.href = linkSource; + downloadLink.download = fileName; + downloadLink.click(); + }), + { + loading: "Exporting...", + success: "Exported", + error: (err) => `Uh oh! Something went wrong: ${err}` + } + ) + } + + function uploadCategories(e) { + utils.showSpinner(); + + if (!e.target.files) { + return; + } + + const file = e.target.files[0]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("deleteExisting", replaceExistingOnUpload); + + toast.promise( + utils.performRequest("/api/categories/import", { + method: "POST", + body: formData + }).then(resp => { + openUploadDialog(false); + utils.hideSpinner(); + }), + { + loading: "Importing...", + success: "Imported", + error: (err) => { + utils.hideSpinner(); + openUploadDialog(false); + + return `Uh oh! Something went wrong: ${err}`; + } + } + ); + } + return ( @@ -169,7 +230,17 @@ export default function CategoriesPage() { Apply Rules - + + } onClick={() => downloadCategories()}> + Export + + + + } onClick={() => openUploadDialog(true)}> + Import + + + @@ -307,6 +378,48 @@ export default function CategoriesPage() { + + + {"Replace Existing Categories?"} + + + + Would you like to replace your existing categories completely with the ones in your import? + ( Note that this will remove all current categories from your transactions ) + + { + setReplaceExistingOnUpload(e.target.checked); + }} + /> + } + label="Replace Existing Categories" + labelPlacement="end" + /> + + + + + Select File + + openUploadDialog(false)} + autoFocus + variant="contained" + > + Cancel + + + ); } \ No newline at end of file diff --git a/frontend/src/app/pages/StatisticsPage.jsx b/frontend/src/app/pages/StatisticsPage.jsx index fd4eaa6..2d7eb78 100644 --- a/frontend/src/app/pages/StatisticsPage.jsx +++ b/frontend/src/app/pages/StatisticsPage.jsx @@ -1,33 +1,277 @@ import * as React from 'react'; -import Grid from '@mui/material/Unstable_Grid2'; -import MediaCard from '@/components/MediaCard'; -import { Stack } from '@mui/material'; +import {Responsive, WidthProvider} from "react-grid-layout"; +import { + Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + Stack, +} from '@mui/material'; +import Grid from "@mui/material/Unstable_Grid2"; +import Button from "@mui/material/Button"; +import {Addchart, Save} from "@mui/icons-material"; +import WidgetContainer from "@/components/widgets/WidgetContainer.jsx"; +import 'react-grid-layout/css/styles.css'; +import {useEffect, useState} from "react"; +import WidgetEditModal from "@/components/widgets/WidgetEditModal.jsx"; +import utils from "@/utils.js"; +import toast from "react-hot-toast"; + +const ResponsiveGridLayout = WidthProvider(Responsive); export default function StatisticsPage() { - return ( - - - - - { + fetchWidgets(); + }, []); + + function fetchWidgets() { + utils.showSpinner(); + + return utils.performRequest("/api/widgets") + .then(resp => resp.json()) + .then(resp => setWidgets((resp.result ?? [])?.map(w => { + return { + i: utils.generateUUID(), + dbId: w.id, + name: w.name, + type: w.type, + x: w.positionX, + y: w.positionY, + h: w.sizeY, + w: w.sizeX, + parameters: w.parameters + } + }) ?? [])) + .then(r => utils.hideSpinner()); + } + + function openWidgetCreationModal() { + setEditedWidget({ + i: utils.generateUUID(), + dbId: undefined, + x: 0, + y: 0, + w: 2, + h: 2 + }); + + openWidgetModal(true); + } + + function openWidgetEditModal(widget) { + setEditedWidget({...widget}); + openWidgetModal(true); + } + + async function createNewWidget(widget) { + utils.showSpinner(); + + await utils.performRequest("/api/widgets", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + positionX: widget.x, + positionY: widget.y, + sizeX: widget.w, + sizeY: widget.h, + name: widget.name, + type: widget.type, + parameters: widget.parameters + }) + }) + .then(resp => resp.json()) + .then(resp => { + widget.dbId = resp.result.createdId; + setWidgets([...widgets, {...widget}]); + openWidgetModal(false); + utils.hideSpinner(); + }); + } + + function saveWidgetsLayout() { + utils.showSpinner(); + + let promises = widgets.map(w => { + return utils.performRequest(`/api/widgets/${w.dbId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + positionX: w.x, + positionY: w.y, + sizeX: w.w, + sizeY: w.h, + name: w.name, + type: w.type, + parameters: w.parameters + }) + }) + }); + + toast.promise( + Promise.resolve(promises) + .then(r => fetchWidgets()) + .then(r => utils.hideSpinner()), + { + loading: "Saving...", + success: "Saved", + error: (err) => `Uh oh, something went wrong!: ${err}` + } + ); + } + + function updateExistingWidget() { + + } + + function removeWidget() { + utils.showSpinner(); + + toast.promise( + utils.performRequest(`/api/widgets/${removingWidgetId}`, { method: "DELETE" }) + .then(resp => fetchWidgets()) + .then(resp => utils.hideSpinner()) + .then(resp => showRemoveWidgetDialog(false)), + { + loading: "Deleting...", + success: "Deleted", + error: (err) => `Uh oh, something went wrong!: ${err}` + } + ); + } + + function saveLayout() { + + } + + function setEditedWidgetState(widget) { + setEditedWidget({...widget}) + + // setWidgets([...widgets.filter(w => w.id !== widget.id), {...widget}]); + } + + return ( + + + + + } + > + Add Widget + + + + } + > + Save Layout + + + + + { + let newWidgets = layout.map(l => { + let widget = widgets.find(w => w.i === l.i); + + widget.x = l.x; + widget.y = l.y; + widget.w = l.w; + widget.h = l.h; + + return widget; + }) + + setWidgets([...newWidgets]) + }} + draggableCancel=".grid-drag-cancel" + cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}} + breakpoints={{lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0}} + > + { + widgets.map((item) => ( + + { + setEditedWidget({...item}); + openWidgetModal(true); + }} + onRemove={(e) => { + setRemovingWidgetId(item.dbId); + showRemoveWidgetDialog(true); + }} + /> + + )) + } + + + + + - - - - - - - - - - - ); + + + + Delete This Widget? + + + + Deleting a widget will permanently remove it from your dashboard and all parameters set for it will be lost. + + + + + Delete + + showRemoveWidgetDialog(false)} + autoFocus + variant="contained" + > + Cancel + + + + + ); } diff --git a/frontend/src/components/widgets/WidgetContainer.jsx b/frontend/src/components/widgets/WidgetContainer.jsx new file mode 100644 index 0000000..62f24de --- /dev/null +++ b/frontend/src/components/widgets/WidgetContainer.jsx @@ -0,0 +1,137 @@ +import Card from "@mui/material/Card"; +import * as React from "react"; +import Divider from "@mui/material/Divider"; +import Typography from "@mui/material/Typography"; +import Grid from "@mui/material/Unstable_Grid2"; +import {Close, Edit} from "@mui/icons-material"; +import {IconButton} from "@mui/material"; +import {useEffect, useState} from "react"; +import utils from "@/utils.js"; +import { PARAMS } from "@/components/widgets/WidgetParameters.js"; +import dayjs from "dayjs"; +import 'chart.js/auto'; +import { Pie } from 'react-chartjs-2'; + +export default function WidgetContainer({widget, sx, onEdit, onRemove}) { + + const [data, setData] = useState(null); + + useEffect(() => { + switch (widget.type) { + case "TOTAL_SPENDING_PER_CATEGORY": { + var queryString = ""; + + console.log(widget); + + queryString += widget.parameters?.filter(p => p.name.includes(PARAMS.CATEGORY_PREFIX)).map(c => `categoryId=${c.numericValue}`)?.join("&"); + + let isToNow = widget.parameters?.find(p => p.name === PARAMS.IS_TO_NOW)?.booleanValue ?? false; + let isFromStatic = widget.parameters?.find(p => p.name === PARAMS.IS_FROM_DATE_STATIC)?.booleanValue ?? false; + + console.log(isToNow); + console.log(isFromStatic); + + var fromDate; + var toDate; + + if (isToNow) { + toDate = dayjs(); + } else { + toDate = dayjs(widget.parameters?.find(p => p.name === PARAMS.TO_DATE)?.timestampValue); + } + + if (!isFromStatic) { + fromDate = dayjs().subtract(widget.parameters?.find(p => p.name === PARAMS.RELATIVE_FROM_PERIOD)?.numericValue ?? 30, 'days'); + } else { + fromDate = dayjs(widget.parameters?.find(p => p.name === PARAMS.FROM_DATE)?.timestampValue); + } + + queryString += `&fromDate=${fromDate}`; + queryString += `&toDate=${toDate}`; + + utils.performRequest(`/api/statistics/totalSpendingByCategory?${queryString}`) + .then(resp => resp.json()) + .then(resp => setData(resp.result)); + + break; + } + case "SPENDING_OVER_TIME_PER_CATEGORY": { + utils.performRequest("/api/statistics/spendingOverTimeByCategory") + .then(resp => resp.json()) + .then(resp => setData(resp.result)); + + break; + } + } + }, [widget]); + + return ( + + + + + + { widget.name } + + + + + + + + + + + + + + + + + + + { + data && widget.type === "TOTAL_SPENDING_PER_CATEGORY" && + c.name), + datasets: [ + { + label: "Amount", + data: data.categories.map(c => data.spendingByCategory[c.id]) + } + ] + }} + /> + } + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/widgets/WidgetEditModal.jsx b/frontend/src/components/widgets/WidgetEditModal.jsx new file mode 100644 index 0000000..01328ca --- /dev/null +++ b/frontend/src/components/widgets/WidgetEditModal.jsx @@ -0,0 +1,341 @@ +import Box from "@mui/material/Box"; +import Divider from "@mui/material/Divider"; +import Grid from "@mui/material/Unstable_Grid2"; +import {Checkbox, FormControlLabel, MenuItem, Modal, OutlinedInput, Select, Slider, TextField} from "@mui/material"; +import Typography from "@mui/material/Typography"; +import utils from "@/utils.js"; +import {DatePicker} from "@mui/x-date-pickers"; +import dayjs from "dayjs"; +import Button from "@mui/material/Button"; +import {Close as CloseIcon, Save as SaveIcon} from "@mui/icons-material"; +import * as React from "react"; +import {useEffect, useState} from "react"; +import { PARAMS } from "@/components/widgets/WidgetParameters.js"; + +export default function WidgetEditModal( + { + initialWidget, + onCreate, + onEdit, + open, + setOpen + } +) { + + const [widget, setWidget] = useState({}); + const [widgetTypes, setWidgetTypes] = useState([]); + const [categories, setCategories] = useState([]); + const [timePeriods, setTimePeriods] = useState([]); + + useEffect(() => { + setWidget({ + ...initialWidget, + selectedCategories: initialWidget.parameters?.filter(p => p.name.includes("category")).map(p => p.numericValue) ?? [] + }); + }, [initialWidget]); + + useEffect(() => { + utils.performRequest("/api/widgets/types") + .then(resp => resp.json()) + .then(resp => setWidgetTypes(resp.result)); + + utils.performRequest("/api/categories") + .then(resp => resp.json()) + .then(resp => setCategories(resp.result)); + + utils.performRequest("/api/statistics/timePeriods") + .then(resp => resp.json()) + .then(resp => setTimePeriods(resp.result)); + }, []); + + function widgetParams(name, defaultValue) { + if (!widget.parameters) { + widget.parameters = []; + } + + let val = widget.parameters?.find(p => p.name === name); + + if (val) { + return val; + } + + let newVal = { + name: name + }; + + defaultValue(newVal); + + widget.parameters.push(newVal); + + setWidget({...widget}); + + return newVal; + } + + function setWidgetParam(name, setParam) { + var param = widget.parameters?.find(p => p.name === name); + + if (!param) { + widget.parameters = [...widget.parameters]; + widget.parameters.push({ + name: name + }); + param = widget.parameters?.find(p => p.name === name); + } + + setParam(param); + setWidget({...widget}); + } + + function mapWidget() { + widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => { + return { + name: `${PARAMS.CATEGORY_PREFIX}-${i}`, + numericValue: c + } + })); + + widget.selectedCategories = undefined; + + return {...widget} + } + + function onEditWidget() { + onEdit(mapWidget()); + } + + function onCreateWidget() { + onCreate(mapWidget()); + } + + return ( + widget && + + + { + widget.dbId ? ( + Editing Widget + ) : ( + Create New Widget + ) + } + + + + setWidget({ + ...widget, + name: e.target.value + })} + autoFocus + sx={{width: "100%"}} + /> + + + setWidget({ + ...widget, + type: e.target.value + })} + > + + Type + + { + widgetTypes.map(wt => ( + + { utils.toPascalCase(wt.replace(/_/g, " ")) } + + )) + } + + + + { + widget.isFromDateStatic ? ( + val.timestampValue = dayjs())?.timestampValue)} + onChange={(newValue) => { + setWidgetParam(PARAMS.FROM_DATE, p => p.timestampValue = newValue); + }} + /> + ) : ( + -x} + max={-7} + track="inverted" + valueLabelDisplay="auto" + valueLabelFormat={(val) => `${val} days`} + value={-widgetParams(PARAMS.RELATIVE_FROM_PERIOD, (val) => val.numericValue = 30)?.numericValue} + onChange={(e, newVal) => { + setWidgetParam(PARAMS.RELATIVE_FROM_PERIOD, (p) => p.numericValue = -newVal); + setWidget({...widget}); + }} + /> + ) + } + + + val.booleanValue = false)?.booleanValue} + onChange={(e) => { + setWidgetParam(PARAMS.IS_FROM_DATE_STATIC, p => p.booleanValue = e.target.checked); + }} + /> + } + label="Static" + labelPlacement="end" + /> + + + val.timestampValue = dayjs())?.timestampValue)} + disabled={widgetParams(PARAMS.IS_TO_NOW, val => val.booleanValue = true)?.booleanValue} + onChange={(newValue) => { + setWidgetParam(PARAMS.TO_DATE, p => p.timestampValue = newValue); + }} + /> + + + val.booleanValue = true)?.booleanValue} + onChange={(e) => { + setWidgetParam(PARAMS.IS_TO_NOW, p => p.booleanValue = e.target.checked); + }} + /> + } + label="To Now" + labelPlacement="end" + /> + + { + widget.type === "SPENDING_OVER_TIME_PER_CATEGORY" && + + val.stringValue = "placeholder")?.stringValue} + onChange={(e) => { + setWidgetParam(PARAMS.TIME_PERIOD, p => p.stringValue = e.target.value); + }} + > + + Time Period + + { + timePeriods.map(wt => ( + + { utils.toPascalCase(wt.replace(/_/g, " ")) } + + )) + } + + + } + + } + multiple + value={widget.selectedCategories ?? []} + renderValue={(selected) => { + console.log(selected) + return ( + { selected.map(s => categories.find(c => c.id === s)?.name)?.join(", ")} + ) + }} + onChange={(e) => { + widget.selectedCategories = e.target.value; + setWidget({...widget}); + }} + > + + Categories + + { + categories.map(c => ( + + cat === c.id) > -1} /> + { c.name } + + )) + } + + + + + { + widget.dbId ? ( + } + > + Update + + ) : ( + } + > + Create + + ) + } + + + setOpen(false)} + startIcon={} + > + Cancel + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/widgets/WidgetParameters.js b/frontend/src/components/widgets/WidgetParameters.js new file mode 100644 index 0000000..a005e4f --- /dev/null +++ b/frontend/src/components/widgets/WidgetParameters.js @@ -0,0 +1,9 @@ +export const PARAMS = { + FROM_DATE: "fromDate", + TO_DATE: "toDate", + RELATIVE_FROM_PERIOD: "relativeFromPeriodInDays", + IS_FROM_DATE_STATIC: "isFromDateStatic", + IS_TO_NOW: "isToDateToNow", + TIME_PERIOD: "timePeriod", + CATEGORY_PREFIX: "category" +} \ No newline at end of file