mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-18 21:59:52 +03:00
Add widgets, Add category import/export
This commit is contained in:
parent
c6c44e1604
commit
6b0f3828a3
33 changed files with 1386 additions and 69 deletions
2
.idea/dataSources.xml
generated
2
.idea/dataSources.xml
generated
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
|
||||
<data-source source="LOCAL" name="finances@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
|
|
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
@ -4,7 +4,7 @@
|
|||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="openjdk-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
|
@ -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'
|
||||
|
|
|
@ -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 <T> ResponseEntity<APIResponseDTO<Object>> emptySuccess() {
|
||||
protected ResponseEntity<APIResponseDTO<Object>> emptySuccess() {
|
||||
return withStatus(HttpStatus.OK, null);
|
||||
}
|
||||
|
||||
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> created(Long id) {
|
||||
protected ResponseEntity<APIResponseDTO<CrudResponseDTO>> 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<APIResponseDTO<byte[]>> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T, ID> extends JpaRepository<T, ID> {
|
||||
|
||||
Page<T> findAllByUserId(int userId, Pageable pageable);
|
||||
|
||||
Collection<T> findAllByUserId(int userId);
|
||||
|
||||
}
|
|
@ -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<APIResponseDTO<CrudResponseDTO>> deleteCategorizationRule(
|
||||
// @PathVariable("categoryId") Long categoryId,
|
||||
// @PathVariable("ruleId") Long ruleId
|
||||
// ) {
|
||||
// return deleted(categoryService.deleteCategorizationRule(ruleId));
|
||||
// }
|
||||
@GetMapping("/export")
|
||||
public ResponseEntity<APIResponseDTO<byte[]>> 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<APIResponseDTO<Collection<CrudResponseDTO>>> 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<APIResponseDTO<Object>> categorizeTransactions(Authentication authentication) {
|
||||
|
|
|
@ -29,8 +29,13 @@ public class StatisticsController extends AbstractRestController {
|
|||
this.statisticsService = statisticsService;
|
||||
}
|
||||
|
||||
@GetMapping("/timePeriods")
|
||||
public ResponseEntity<APIResponseDTO<TimePeriod[]>> 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<APIResponseDTO<SpendingByCategoriesDTO>> 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<APIResponseDTO<SpendingOverTimeByCategoryDTO>> fetchSpendingOverTimeByCategory(
|
||||
Long[] categoryId,
|
||||
@RequestParam(defaultValue = "DAILY") TimePeriod period,
|
||||
|
|
|
@ -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 {
|
||||
|
||||
// <CreateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> create(CreateDTO dto);
|
||||
//
|
||||
// <UpdateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> update(Long id, UpdateDTO dto);
|
||||
//
|
||||
// ResponseEntity<APIResponseDTO<CrudResponseDTO>> delete(Long id);
|
||||
private final WidgetService widgetService;
|
||||
|
||||
public WidgetsController(WidgetService widgetService) {
|
||||
this.widgetService = widgetService;
|
||||
}
|
||||
|
||||
@GetMapping("/types")
|
||||
public ResponseEntity<APIResponseDTO<WidgetType[]>> fetchWidgetTypes() {
|
||||
return ok(WidgetType.values());
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<APIResponseDTO<Collection<WidgetDTO>>> fetchAllForUser(Authentication authentication) {
|
||||
return ok(widgetService.fetchAllForUser(Integer.parseInt(authentication.getName())));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> 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<APIResponseDTO<CrudResponseDTO>> 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<APIResponseDTO<CrudResponseDTO>> delete(@PathVariable Long widgetId) {
|
||||
return deleted(widgetService.deleteWidget(widgetId));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import java.time.LocalDateTime;
|
|||
public record CategorizationDTO(
|
||||
Long id,
|
||||
|
||||
Long categoryId,
|
||||
|
||||
CategorizationRuleDTO rule,
|
||||
|
||||
ProcessedTransactionFieldDTO ruleBasedOn,
|
||||
|
|
|
@ -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<CreateWidgetParameterDTO> parameters
|
||||
) {}
|
|
@ -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
|
||||
) {}
|
|
@ -0,0 +1,9 @@
|
|||
package dev.mvvasilev.finances.dtos;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public record ImportExportCategoriesDTO(
|
||||
Collection<CategoryDTO> categories,
|
||||
|
||||
Collection<CategorizationDTO> categorizationRules
|
||||
) {}
|
|
@ -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<WidgetParameterDTO> parameters
|
||||
) {}
|
|
@ -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
|
||||
) {}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -38,4 +38,6 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
|
|||
nativeQuery = true
|
||||
)
|
||||
Collection<TransactionCategory> fetchCategoriesForTransaction(@Param("transactionId") Long transactionId);
|
||||
|
||||
void deleteAllByUserId(@Param("userId") Integer userId);
|
||||
}
|
||||
|
|
|
@ -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<WidgetParameter, Long> {
|
||||
|
||||
Collection<WidgetParameter> findAllByWidgetIdIn(Collection<Long> widgetIds);
|
||||
|
||||
}
|
|
@ -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<Widget, Long> {
|
||||
}
|
|
@ -281,6 +281,7 @@ public class CategoryService {
|
|||
private CategorizationDTO mapCategorization(final Collection<Categorization> 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<Long> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<WidgetParameterDTO> 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<WidgetDTO> 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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
CREATE SCHEMA IF NOT EXISTS statistics;
|
||||
|
||||
CREATE OR REPLACE FUNCTION statistics.spending_over_time(
|
||||
category_ids BIGINT[],
|
||||
time_period TEXT,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
);
|
88
frontend/package-lock.json
generated
88
frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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 (
|
||||
<Grid container spacing={1}>
|
||||
|
||||
|
@ -169,7 +230,17 @@ export default function CategoriesPage() {
|
|||
Apply Rules
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={10} lg={10}></Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<Download />} onClick={() => downloadCategories()}>
|
||||
Export
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<Upload />} onClick={() => openUploadDialog(true)}>
|
||||
Import
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={8} lg={8}></Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} lg={12}>
|
||||
|
@ -307,6 +378,48 @@ export default function CategoriesPage() {
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={showUploadDialog}
|
||||
>
|
||||
<DialogTitle id="upload-dialog-title">
|
||||
{"Replace Existing Categories?"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
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 )
|
||||
</DialogContentText>
|
||||
<FormControlLabel
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
value="end"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={replaceExistingOnUpload}
|
||||
onChange={(e) => {
|
||||
setReplaceExistingOnUpload(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Replace Existing Categories"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
component="label"
|
||||
>
|
||||
<VisuallyHiddenInput type="file" onChange={uploadCategories}/>
|
||||
Select File
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openUploadDialog(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Grid>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<Stack>
|
||||
<div>
|
||||
<Grid container rowSpacing={3} columnSpacing={3}>
|
||||
<Grid xs={4}>
|
||||
<MediaCard
|
||||
heading="CMYK"
|
||||
text="The CMYK color model (also known as process color, or four color) is a subtractive color model, based on the CMY color model, used in color printing, and is also used to describe the printing process itself."
|
||||
|
||||
const [layout, setLayout] = useState([]);
|
||||
|
||||
const [widgets, setWidgets] = useState([]);
|
||||
const [editedWidget, setEditedWidget] = useState({});
|
||||
const [isWidgetModalOpen, openWidgetModal] = useState(false);
|
||||
const [isRemoveWidgetDialogShown, showRemoveWidgetDialog] = useState(false);
|
||||
const [removingWidgetId, setRemovingWidgetId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<Stack>
|
||||
<Grid container spacing={1}>
|
||||
<Grid container xs={12} lg={12}>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button
|
||||
sx={{
|
||||
width: "100%"
|
||||
}}
|
||||
variant="contained"
|
||||
onClick={openWidgetCreationModal}
|
||||
startIcon={<Addchart/>}
|
||||
>
|
||||
Add Widget
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button
|
||||
sx={{
|
||||
width: "100%"
|
||||
}}
|
||||
variant="contained"
|
||||
onClick={saveWidgetsLayout}
|
||||
startIcon={<Save/>}
|
||||
>
|
||||
Save Layout
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={12}>
|
||||
<ResponsiveGridLayout
|
||||
onLayoutChange={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) => (
|
||||
<div
|
||||
key={item.i}
|
||||
data-grid={{
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
resizeHandles: ["se"]
|
||||
}}
|
||||
>
|
||||
<WidgetContainer
|
||||
widget={item}
|
||||
onEdit={(e) => {
|
||||
setEditedWidget({...item});
|
||||
openWidgetModal(true);
|
||||
}}
|
||||
onRemove={(e) => {
|
||||
setRemovingWidgetId(item.dbId);
|
||||
showRemoveWidgetDialog(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ResponsiveGridLayout>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<WidgetEditModal
|
||||
initialWidget={editedWidget}
|
||||
onCreate={createNewWidget}
|
||||
onEdit={updateExistingWidget}
|
||||
open={isWidgetModalOpen}
|
||||
setOpen={openWidgetModal}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={4}>
|
||||
<MediaCard
|
||||
heading="HSL and HSV"
|
||||
text="HSL (for hue, saturation, lightness) and HSV (for hue, saturation, value; also known as HSB, for hue, saturation, brightness) are alternative representations of the RGB color model, designed in the 1970s by computer graphics researchers."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={4}>
|
||||
<MediaCard
|
||||
heading="RGB"
|
||||
text="An RGB color space is any additive color space based on the RGB color model. RGB color spaces are commonly found describing the input signal to display devices such as television screens and computer monitors."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
<Dialog
|
||||
open={isRemoveWidgetDialogShown}
|
||||
>
|
||||
<DialogTitle id="delete-widget-dialog-title">
|
||||
Delete This Widget?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Deleting a widget will permanently remove it from your dashboard and all parameters set for it will be lost.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={removeWidget}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => showRemoveWidgetDialog(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
137
frontend/src/components/widgets/WidgetContainer.jsx
Normal file
137
frontend/src/components/widgets/WidgetContainer.jsx
Normal file
|
@ -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 (
|
||||
<Card
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
p: 2,
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
<Grid container>
|
||||
<Grid container xs={12} lg={12}>
|
||||
<Grid xs={10} lg={10}>
|
||||
<Typography
|
||||
sx={{
|
||||
width: "100%",
|
||||
fontSize: "1.4em"
|
||||
}}
|
||||
>
|
||||
{ widget.name }
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<IconButton
|
||||
sx={{ width: "100%" }}
|
||||
onClick={onEdit}
|
||||
className={"grid-drag-cancel"}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<IconButton
|
||||
sx={{ width: "100%" }}
|
||||
onClick={onRemove}
|
||||
className={"grid-drag-cancel"}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={12}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={12}>
|
||||
<div style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||
{
|
||||
data && widget.type === "TOTAL_SPENDING_PER_CATEGORY" &&
|
||||
<Pie
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1
|
||||
}}
|
||||
data={{
|
||||
labels: data.categories.map(c => c.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Amount",
|
||||
data: data.categories.map(c => data.spendingByCategory[c.id])
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
}
|
341
frontend/src/components/widgets/WidgetEditModal.jsx
Normal file
341
frontend/src/components/widgets/WidgetEditModal.jsx
Normal file
|
@ -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 &&
|
||||
<Modal
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
height: "fit-content",
|
||||
p: 4
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<Box>
|
||||
{
|
||||
widget.dbId ? (
|
||||
<h3>Editing Widget</h3>
|
||||
) : (
|
||||
<h3>Create New Widget</h3>
|
||||
)
|
||||
}
|
||||
<Divider></Divider>
|
||||
<Grid container spacing={1}>
|
||||
<Grid xs={12} lg={12}>
|
||||
<TextField
|
||||
id="widget-name"
|
||||
label="Name"
|
||||
variant="outlined"
|
||||
value={widget.name ?? ""}
|
||||
onChange={(e) => setWidget({
|
||||
...widget,
|
||||
name: e.target.value
|
||||
})}
|
||||
autoFocus
|
||||
sx={{width: "100%"}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={12}>
|
||||
<Select
|
||||
id={"widget-type"}
|
||||
sx={{ width: "100%" }}
|
||||
value={widget.type ?? "placeholder"}
|
||||
onChange={(e) => setWidget({
|
||||
...widget,
|
||||
type: e.target.value
|
||||
})}
|
||||
>
|
||||
<MenuItem value={"placeholder"} disabled>
|
||||
<Typography sx={{ color: 'gray' }}>Type</Typography>
|
||||
</MenuItem>
|
||||
{
|
||||
widgetTypes.map(wt => (
|
||||
<MenuItem
|
||||
key={wt}
|
||||
value={wt}
|
||||
>
|
||||
<Typography>{ utils.toPascalCase(wt.replace(/_/g, " ")) }</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</Grid>
|
||||
<Grid xs={8} lg={8}>
|
||||
{
|
||||
widget.isFromDateStatic ? (
|
||||
<DatePicker
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
label="From"
|
||||
value={dayjs(widgetParams(PARAMS.FROM_DATE, val => val.timestampValue = dayjs())?.timestampValue)}
|
||||
onChange={(newValue) => {
|
||||
setWidgetParam(PARAMS.FROM_DATE, p => p.timestampValue = newValue);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Slider
|
||||
sx={{ width: "98%" }}
|
||||
label={"From"}
|
||||
min={-365}
|
||||
scale={x => -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});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Grid>
|
||||
<Grid xs={4} lg={4}>
|
||||
<FormControlLabel
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
value="end"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={widgetParams(PARAMS.IS_FROM_DATE_STATIC, val => val.booleanValue = false)?.booleanValue}
|
||||
onChange={(e) => {
|
||||
setWidgetParam(PARAMS.IS_FROM_DATE_STATIC, p => p.booleanValue = e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Static"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={8} lg={8}>
|
||||
<DatePicker
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
label="To"
|
||||
value={dayjs(widgetParams(PARAMS.TO_DATE, val => 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);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={4} lg={4}>
|
||||
<FormControlLabel
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
value="end"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={widgetParams(PARAMS.IS_TO_NOW, val => val.booleanValue = true)?.booleanValue}
|
||||
onChange={(e) => {
|
||||
setWidgetParam(PARAMS.IS_TO_NOW, p => p.booleanValue = e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="To Now"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Grid>
|
||||
{
|
||||
widget.type === "SPENDING_OVER_TIME_PER_CATEGORY" &&
|
||||
<Grid xs={12} lg={12}>
|
||||
<Select
|
||||
id={"time-period-type"}
|
||||
sx={{ width: "100%" }}
|
||||
value={widgetParams(PARAMS.TIME_PERIOD, val => val.stringValue = "placeholder")?.stringValue}
|
||||
onChange={(e) => {
|
||||
setWidgetParam(PARAMS.TIME_PERIOD, p => p.stringValue = e.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value={"placeholder"} disabled>
|
||||
<Typography sx={{ color: 'gray' }}>Time Period</Typography>
|
||||
</MenuItem>
|
||||
{
|
||||
timePeriods.map(wt => (
|
||||
<MenuItem
|
||||
key={wt}
|
||||
value={wt}
|
||||
>
|
||||
<Typography>{ utils.toPascalCase(wt.replace(/_/g, " ")) }</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</Grid>
|
||||
}
|
||||
<Grid xs={12} lg={12}>
|
||||
<Select
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
input={<OutlinedInput label="Categories" />}
|
||||
multiple
|
||||
value={widget.selectedCategories ?? []}
|
||||
renderValue={(selected) => {
|
||||
console.log(selected)
|
||||
return (<Typography>
|
||||
{ selected.map(s => categories.find(c => c.id === s)?.name)?.join(", ")}
|
||||
</Typography>)
|
||||
}}
|
||||
onChange={(e) => {
|
||||
widget.selectedCategories = e.target.value;
|
||||
setWidget({...widget});
|
||||
}}
|
||||
>
|
||||
<MenuItem value="placeholder" disabled>
|
||||
<Typography sx={{ color: 'gray' }}>Categories</Typography>
|
||||
</MenuItem>
|
||||
{
|
||||
categories.map(c => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
<Checkbox checked={widget.selectedCategories?.findIndex(cat => cat === c.id) > -1} />
|
||||
<Typography>{ c.name }</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={6} lg={6}>
|
||||
{
|
||||
widget.dbId ? (
|
||||
<Button
|
||||
sx={{width: "100%"}}
|
||||
variant="contained"
|
||||
onClick={onEditWidget}
|
||||
startIcon={<SaveIcon />}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
sx={{width: "100%"}}
|
||||
variant="contained"
|
||||
onClick={onCreateWidget}
|
||||
startIcon={<SaveIcon />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</Grid>
|
||||
<Grid xs={6} lg={6}>
|
||||
<Button
|
||||
sx={{width: "100%"}}
|
||||
onClick={() => setOpen(false)}
|
||||
startIcon={<CloseIcon />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
9
frontend/src/components/widgets/WidgetParameters.js
Normal file
9
frontend/src/components/widgets/WidgetParameters.js
Normal file
|
@ -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"
|
||||
}
|
Loading…
Add table
Reference in a new issue